Conditional Types and infer

Advanced
13 min

Conditional Types and infer

Conditional types let a type depend on another type, exactly as a ternary expression lets a value depend on a condition. They are the engine behind ReturnType, Exclude, Awaited and most library typings that "know" the result of an operation from its inputs. This lesson teaches the syntax, the distributive behaviour over unions that surprises most people, and the infer keyword for pulling types apart.

Syntax and Evaluation

typescript
type Check<T> = T extends string ? "text" : "other"; type A = Check<"hello">; // "text" type B = Check<42>; // "other"

T extends U ? X : Y reads: if T is assignable to U, the result is X, otherwise Y. Branches may themselves be conditional types, giving type-level if/else if chains:

typescript
type TypeName<T> = T extends string ? "string" : T extends number ? "number" : T extends boolean ? "boolean" : T extends (...args: any[]) => any ? "function" : "object"; type N = TypeName<() => void>; // "function"

While T is still an unresolved type parameter the conditional is deferred and stays unevaluated until a concrete type is supplied.

Distribution Over Unions

When the checked type is a naked type parameter and you pass a union, the conditional is applied to each member separately and the results are unioned. This is called a distributive conditional type:

typescript
type ToArray<T> = T extends unknown ? T[] : never; type R1 = ToArray<string | number>; // string[] | number[] (distributed)

Distribution is what makes the built-in filters work:

typescript
type MyExclude<T, U> = T extends U ? never : T; type MyExtract<T, U> = T extends U ? T : never; type MyNonNullable<T> = T extends null | undefined ? never : T; type Status = "idle" | "loading" | "done" | "error"; type Busy = MyExclude<Status, "idle" | "done">; // "loading" | "error"

Each member that matches becomes never, and never vanishes from a union. Two consequences follow:

  • ToArray<never> is never, because distributing over the empty union yields nothing.
  • To disable distribution, wrap both sides in a tuple: [T] extends [unknown] ? T[] : never, which gives (string | number)[] for the example above.

Extracting Types With infer

infer declares a type variable inside the extends clause. If the match succeeds, that variable holds the corresponding part of the type:

typescript
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never; type Ret<F> = F extends (...args: any[]) => infer R ? R : never; type PromiseValue<P> = P extends Promise<infer V> ? V : P; type TupleHead<T> = T extends [infer H, ...unknown[]] ? H : never; type A = FirstArg<(id: number, name: string) => void>; // number type B = Ret<typeof JSON.parse>; // any type C = PromiseValue<Promise<Blob>>; // Blob type D = TupleHead<[boolean, string]>; // boolean

The built-in ReturnType<T>, Parameters<T>, ConstructorParameters<T>, InstanceType<T> and Awaited<T> are all defined this way. Since TypeScript 4.7 an inferred variable can carry its own constraint, infer U extends string, which avoids a second conditional check.

Recursive Conditional Types

A conditional type may reference itself, which unlocks deep transformations:

typescript
type DeepAwaited<T> = T extends Promise<infer V> ? DeepAwaited<V> : T; type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T; type F = Flatten<number[][][]>; // number type Join<T extends string[], Sep extends string> = T extends [infer H extends string, ...infer R extends string[]] ? R extends [] ? H : `${H}${Sep}${Join<R, Sep>}` : ""; type Path = Join<["users", "42", "posts"], "/">; // "users/42/posts"

The compiler limits recursion depth (roughly 50 levels for non-tail positions, more for tail-recursive forms), so keep these for bounded structures such as tuples and nested objects.

Where Conditional Types Pay Off

| Need | Conditional type | |---|---| | Different return type per input type | T extends string ? number : string | | Remove members from a union | Exclude<T, U> | | Read a function's result type | ReturnType<T> via infer | | Unwrap wrappers (Promise, arrays) | infer on the wrapper's type argument | | Compute a literal from a tuple | Recursive conditional with infer |

Common mistakes

  • Expecting T extends string ? ... : ... to narrow a value; conditional types operate on types only. Use overloads or guards for runtime behaviour.
  • Forgetting distribution and getting string[] | number[] when (string | number)[] was intended.
  • Leaking any into results; (...args: any[]) => any is fine for matching, but prefer unknown elsewhere.
Quick Quiz
Question 1 of 3

What is `ToArray<string | number>` when `type ToArray<T> = T extends unknown ? T[] : never`?

Key Takeaways

  • T extends U ? X : Y selects a type based on assignability and can be chained like if/else.
  • With a naked type parameter, conditional types distribute over union members; wrap in [T] to prevent it.
  • never results vanish from unions, which is how Exclude, Extract and NonNullable filter.
  • infer captures part of a matched type, enabling ReturnType, Parameters, Awaited and custom unwrappers.
  • Recursive conditional types transform nested structures but are subject to a depth limit.

Next lesson: Mapped Types and Key Remapping — build new object types by iterating over the keys of existing ones.

Conditional Types and infer - TypeScript | CodeYourCraft | CodeYourCraft