Alright, let’s get our hands dirty. You’ve seen built-in utility types like Extract<T, U> and ReturnType<T>. They seem like magic, right? Well, pull back the curtain and you’ll find they’re just cleverly applied conditional types, often powered by the infer keyword. This is where we move from understanding conditionals to wielding them.

Let’s start by demystifying a few of the big ones. You’ll see a pattern emerge: a conditional type checks for a structural match, and infer pulls out the piece we actually care about.

How ReturnType Actually Works

ReturnType<T> is the classic example. Its job is to extract the type of what a function returns. Here’s its actual implementation, which you can find in the lib.es5.d.ts file:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

Let’s break down this masterpiece of type-sniping. First, it constrains T to be any kind of function ((...args: any) => any). This is a good practice; it provides a clearer error message if you pass it a string.

Now for the conditional: T extends (...args: any) => infer R. This reads as: “Does type T match the shape of a function that takes any arguments and returns something?” The magic is infer R. It’s like a declarative variable that says, “If this pattern matches, grab the type in the return’s position and assign it to R for me to use.”

If the pattern matches, the type resolves to R (the inferred return type). If you pass it something that isn’t a function (which the constraint should prevent), it falls back to any.

function getString() {
  return "hello world";
}
function getComplexObject() {
  return { id: 1, name: "Alice" };
}

// Type is string
type StringReturn = ReturnType<typeof getString>;
// Type is { id: number; name: string; }
type ObjectReturn = ReturnType<typeof getComplexObject>;

Deconstructing Parameters

The Parameters<T> utility type is its sibling, grabbing the tuple of a function’s parameter types. Its implementation is a beautiful mirror of ReturnType:

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

See the shift? Now we’re inferring P from the arguments position. If T matches a function shape, we get the tuple type P. If not, we get never (a slightly harsher fallback than any, which makes sense—a non-function has no parameters).

function logMessage(level: 'info' | 'error', message: string, timestamp: Date) {
  console.log(`[${level}] ${timestamp.toISOString()}: ${message}`);
}

// Type is [level: 'info' | 'error', message: string, timestamp: Date]
type LogParams = Parameters<typeof logMessage>;

The Power and Peril of infer

The infer keyword is the star here, but it has a crucial limitation you must understand: it only works in the extends clause of a conditional type. You can’t just use it willy-nilly elsewhere. It’s a pattern-matching tool, not a general assignment operator.

This leads to a common pitfall: trying to infer multiple things at once in a disjointed way. You can infer multiple parts of a structure in a single clause, but they must be part of a cohesive pattern.

For example, this works for a function:

type FunctionParts<T> = T extends (...args: infer A) => infer R ? { args: A; return: R } : never;

But trying to infer two unrelated things in a union is a fool’s errand. The conditional type checks against a single, specific pattern.

When the Match Fails

The most important thing to internalize is that these utilities are built on structural typing. ReturnType doesn’t care if your function is named getUser; it only cares if it has a call signature that returns something.

This is usually what you want, but it can lead to surprising results with certain function types, like overloads. If you have a function with multiple call signatures, TypeScript can only effectively infer types by matching against the last signature declared. This is one of those questionable choices—it’s pragmatic for the compiler but confusing for developers. Always be cautious when applying these to overloaded functions.

The best practice? Use these utilities as building blocks for your own more sophisticated type logic. Don’t just stop at ReturnType; create a AsyncReturnType that unwraps a Promise. That’s how you move from using the tools to building with them.

type AsyncReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => Promise<infer R> ? R : any;

async function fetchUser() {
  return { id: 1, name: 'Alice' };
}

// Type is { id: number; name: string; }
type User = AsyncReturnType<typeof fetchUser>;

See? No magic. Just a precise application of a conditional type and the infer keyword. You’re not just reading about these types anymore; you’re understanding how to construct them.