Conditional Types: Branching Logic at the Type Level

Learn to write types that branch on a condition using the extends keyword, understand how conditional types distribute over unions, and use infer to extract types out of other types, including recursively.

Conditional types let a type definition make a decision — 'if this type is assignable to that type, produce one result, otherwise produce another' — using syntax that deliberately mirrors JavaScript's ternary operator. They are what makes utilities like Exclude, ReturnType, and Awaited possible, and once you understand distribution and infer, you can build sophisticated compile-time logic of your own.

Basic conditional syntax with extends

A conditional type has the form T extends U ? X : Y — read as 'if T is assignable to U, the result is X, otherwise the result is Y'. This is evaluated purely at compile time; nothing about it exists in the emitted JavaScript. The example type Result<T> = T extends string ? string[] : number[]; produces string[] when T is instantiated with a string type, and number[] otherwise.

The extends check here is an assignability check, the same one TypeScript performs when you assign a value of one type to a variable of another. It's not equality — T extends string is true not just for exactly string, but for any subtype of string, such as a string literal type like "hello".

Conditional types are almost always used inside a generic type alias, since a conditional type with no generic parameter would just resolve immediately to one branch and could be written as a plain type instead. Their real value comes from being deferred — evaluated later, once a concrete type is substituted in.

Distributive conditional types over unions

When the checked type in a conditional type is a bare, naked type parameter (not wrapped in anything, like T extends U ? X : Y where T is directly a generic parameter) and you instantiate it with a union, TypeScript distributes the conditional over each member of the union separately, then unions the results back together. So type ToArray<T> = T extends any ? T[] : never; applied to string | number doesn't produce (string | number)[] — it produces string[] | number[], because it's evaluated once for string and once for number.

This distributive behavior is usually exactly what you want, but it can be turned off by wrapping the checked type in a tuple: [T] extends [U] ? X : Y. Wrapping both sides in single-element tuples forces the comparison to happen against the union as a single, whole type rather than distributing member by member — a common trick when you specifically want to check 'is this whole union assignable' rather than 'check each member individually'.

Distribution is also why Exclude<T, U> works the way it does: it's defined as T extends U ? never : T, and because it distributes, each member of T is checked against U individually, with matching members dropped out of the resulting union entirely (since never disappears from a union).

Nested conditional types and infer

Conditional types can be chained, producing what's effectively a type-level if/else-if/else ladder: T extends A ? "a" : T extends B ? "b" : "other". Each false branch is itself another conditional type, checked in order, exactly like nested ternaries in JavaScript — and just as with nested ternaries, indentation matters a great deal for readability once you go past two or three branches.

The infer keyword, used only inside the extends clause of a conditional type, introduces a new type variable that TypeScript fills in by pattern-matching against the structure being checked. type ElementType<T> = T extends (infer U)[] ? U : never; says: if T is an array, capture whatever its element type is into U and return U; otherwise return never. This is how TypeScript looks 'inside' a generic structure to pull out a piece of it.

infer is what powers most of the compiler's own function-related utilities. ReturnType<T> is essentially T extends (...args: any[]) => infer R ? R : never — it matches the shape of a function type and captures whatever sits in the return position, without you ever having to specify the return type by hand.

Recursive conditional types and their limits

A conditional type is allowed to reference itself in one of its branches, which lets you write genuinely recursive type-level algorithms — walking a nested array to find its innermost element type, flattening a deeply nested Promise, or building a deep Readonly<T> that also makes every nested object's properties readonly, not just the top level.

Recursive conditional types are evaluated the same way recursive functions are: each recursive reference peels off one layer and hands the rest to another instantiation of the same type. type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T; keeps recursing into nested objects until it hits a primitive, which becomes the base case that stops the recursion.

Because this is happening at compile time with no true tail-call optimization, TypeScript imposes a recursion depth limit and will report an error like 'Type instantiation is excessively deep and possibly infinite' if a recursive conditional type nests too many levels for a given input, or if it can't prove the recursion terminates. Deeply recursive conditional types can also noticeably slow down compilation on large codebases, so they're best reserved for genuinely necessary structural transformations rather than used casually.

Conditional type syntax, distribution, and infer

extends in a conditional type checks assignability, not exact equality. Distribution only happens for a bare, unwrapped type parameter; wrap both sides in [ ] to compare the whole union at once instead. infer can only appear inside the extends clause, and each infer introduces one new type variable available in the true branch.

type Result<T> =
  T extends string ? string[] : number[];

type A = Result<string>;  // string[]
type B = Result<number>;  // number[]

// Distributive over unions
type ToArray<T> = T extends any ? T[] : never;
type C = ToArray<string | number>;  // string[] | number[]

// infer pulls a type out of a matching shape
type ElementType<T> = T extends (infer U)[] ? U : never;
type D = ElementType<number[]>;  // number

// Recursive conditional type
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

Code Examples

IsString checks whether T is assignable to string. Because "hello" is a string literal (assignable to string), X resolves to true; because 42 is a number, Y resolves to false. This is the simplest possible conditional type: a compile-time boolean check. Expected result: type X = true; type Y = false;

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

UnwrapPromise uses infer to capture the value type inside a Promise, falling back to T unchanged if T isn't a Promise at all. DeepUnwrap makes this recursive: each time it matches a Promise, it recurses on the inner type, so it keeps peeling away nested promises until it hits a non-Promise, which becomes the final result.

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

Common mistake: Writing UnwrapPromise without the recursive call only unwraps one layer — Promise<Promise<string>> would incorrectly resolve to Promise<string> instead of string.

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

type X = IsString<"hello">;  // true
type Y = IsString<42>;       // false
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>;  // string
type B = UnwrapPromise<number>;           // number (not wrapped, returned as-is)

// Recursive version that keeps unwrapping nested promises
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;

type C = DeepUnwrap<Promise<Promise<Promise<boolean>>>>;  // boolean

Distribution over a union vs. a wrapped comparison

A bare type parameter causes the conditional to run once per union member and union the results; wrapping in tuples compares the union as a single unit instead.

T extends U ? X : Y     (T is a bare type parameter)

ToArray<string | number>
   |  splits into each member
   v
ToArray<string> | ToArray<number>
   |                    |
 string[]            number[]
   \____________  ____________/
                \/
        string[] | number[]   <- final unioned result

[T] extends [U] ? X : Y   <- wrapping in [ ] disables distribution,
                              comparing the whole union at once

Important Rules

T extends U ? X : Y checks assignability (is T assignable to U), not strict equality.

Conditional types only distribute over a union when the checked type is a bare, unwrapped generic type parameter; wrap both sides in [T] extends [U] to disable distribution.

infer may only be used inside the extends clause of a conditional type, and introduces a new inferred type variable usable in the true branch.

A recursive conditional type needs a genuine base case (a branch that doesn't recurse) or TypeScript will report excessively deep or infinite instantiation.

Deeply nested or heavily recursive conditional types can slow compilation noticeably — reserve them for real structural transformations, not casual use.

Where this shows up

Conditional types with infer are the mechanism behind ReturnType<T>, Parameters<T>, and Awaited<T> — all central to typing async code, higher-order functions, and API client wrappers where you want a derived type to automatically track whatever a function or Promise actually returns, without duplicating it by hand. Form and validation libraries use distributive conditional types to turn a union of field configs into a union of matching value types, so the compiler flags a field being assigned the wrong kind of value at the call site rather than at runtime.

type NoNull<T> = T extends null ? never: T; // used expecting it to run once on a union as a whole type NoNull<T> = [T] extends [null] ? never: T; // when whole-union comparison is intended

With a bare T, NoNull<string | null> distributes and correctly filters out null (giving string), which is usually desired. But if the intent was to check 'is this entire union exactly null', the bare form gives misleading results for unions — wrapping in tuples forces a single, non-distributed comparison when that's genuinely what's needed.

type Flatten<T> = T extends Array<infer U> ? Flatten<U>: T; // no base case check for non-arrays of primitives type Flatten<T> = T extends Array<infer U> ? Flatten<U>: T; // this IS correct — U eventually stops matching Array and returns T

This pattern is actually correct because the recursion naturally terminates: once U is no longer an array, the extends check fails and the non-recursive branch (T) is returned, forming the base case implicitly. The mistake to watch for is forgetting this fallback branch entirely, which would leave no way to stop recursing.

[predict-output] What does type ToArray<T> = T extends any ? T[] : never; produce when instantiated as ToArray<string | number>? A. (string | number)[] B. string[] | number[] C. never D. any[]

B. string[] | number[] — Because T is a bare type parameter, the conditional distributes over the union, running once for string and once for number, producing string[] | number[] after the results are unioned back together.

[multiple-choice] Where can the infer keyword legally appear? A. Anywhere in a type alias B. Only inside the extends clause of a conditional type C. Only inside a mapped type D. Only inside a function's parameter list

B. Only inside the extends clause of a conditional type — infer is only valid inside the extends clause of a conditional type, where it introduces a new type variable inferred by matching against that structure.

[multiple-choice] What stops a recursive conditional type from recursing forever? A. TypeScript automatically memoizes all recursive types B. A base case branch that doesn't recurse, reached once the condition stops matching C. Recursive conditional types are not allowed at all D. The compiler runs it at most once regardless of input

B. A base case branch that doesn't recurse, reached once the condition stops matching — Just like a recursive function, a recursive conditional type needs a branch where the extends check fails and the type is returned directly rather than recursing again; without one, or if TypeScript can't bound the depth, it reports an excessively deep or infinite instantiation error.

Write a conditional type Flatten<T> that, given an array type, returns its element type, and otherwise returns T unchanged. Then write FunctionReturn<T> using infer that extracts the return type of a function type, or never if T isn't a function.

type Flatten<T> = T extends (infer U)[] ? U : T; type A = Flatten<string[]>; // string type B = Flatten<number>; // number type FunctionReturn<T> = T extends (...args: any[]) => infer R ? R : never; type C = FunctionReturn<() => boolean>; // boolean

Flatten needs T extends (infer U)[] ? U : T. FunctionReturn needs T extends (...args: any[]) => infer R ? R : never.