Conditional Types & infer

advanced conditional-types infer advanced-types

In simple language, a conditional type is an if-else that runs at compile time. The syntax is T extends U ? X : Y — read as “if T is assignable to U, the type is X, otherwise it’s Y”. That’s it. Everything else is just clever use of this one shape.

The basic branching

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;  // true
type B = IsString<42>;       // false
type C = IsString<string>;   // true

Notice we get a literal true or false back — not just boolean. That’s because TS evaluates the branch and picks one. We can use this to narrow types based on what was passed in.

T extends U ? X : Y
T extends U?
yes ↓
type = X
no ↓
type = Y

Distributive behavior — the gotcha

When the thing on the left of extends is a “naked” generic and we pass it a union, the conditional distributes over the union. It runs the conditional once per member and unions the results.

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;
// Distributes: ToArray<string> | ToArray<number>
// = string[] | number[]
// NOT (string | number)[]

To prevent distribution, wrap both sides in a tuple. This is a real-world trick used all over library types.

type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;

type Result2 = ToArrayNoDistribute<string | number>;
// (string | number)[]

infer — extract a type from a position

infer is the real magic. It lets us pattern-match on a type and pull out a piece of it. Think of it as a wildcard with a name — TS figures out what fits in that slot and binds it to the variable.

// Pull the return type out of any function type
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type R1 = MyReturnType<() => string>;          // string
type R2 = MyReturnType<(x: number) => Date>;   // Date
type R3 = MyReturnType<"not a function">;      // never

infer R says “match whatever appears here and call it R”. If the pattern matches, we get R in the true branch. This is literally how TS’s built-in ReturnType<T> is defined.

Real-world infer patterns

These show up everywhere — knowing them is interview gold.

// Get the type a Promise resolves to
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type X = UnwrapPromise<Promise<User>>;  // User

// Get the first parameter of a function
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type Y = FirstParam<(name: string, age: number) => void>; // string

// Get the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Z = ElementOf<User[]>; // User

// Get all parameters as a tuple — this is Parameters<T>
type Params<T> = T extends (...args: infer A) => any ? A : never;
type W = Params<(a: string, b: number) => void>; // [string, number]

Recursive conditional types

Conditionals can call themselves. Awaited<T> in the stdlib uses this — it unwraps nested promises until it hits a non-promise.

type DeepAwait<T> = T extends Promise<infer U> ? DeepAwait<U> : T;

type Final = DeepAwait<Promise<Promise<Promise<string>>>>; // string

We keep peeling layers off until the condition fails, then return whatever’s left. TS limits recursion depth to keep the compiler from exploding, but for sane shapes it just works.

Putting it together — a typed event emitter

Here’s something we’d actually write. Given a map of event names to payload types, derive the listener signature for any event.

type Events = {
  login: { userId: string };
  logout: { reason: string };
};

type Listener<E extends keyof Events> = (payload: Events[E]) => void;

type ListenerOf<E> = E extends keyof Events
  ? (payload: Events[E]) => void
  : never;

const onLogin: ListenerOf<"login"> = (p) => console.log(p.userId);
// const bad: ListenerOf<"login"> = (p) => console.log(p.reason); // error

Conditional types + infer together are how every “magic” library type is built. Once these click, the rest of advanced TS feels obvious.