Kodokon kodokon.com

Conditional types and infer: the compiler's logic

Master the distributivity of conditional types, pattern matching with infer, and edge cases like never, any, and boolean.

10 min · 3 questions

Open this lesson in Kodokon

Conditional types are the branching mechanism of the type system: T extends U ? X : Y asks the compiler whether T is assignable to U, then selects a branch. All the standard utilities such as ReturnType, Exclude, or Awaited are built on them. At this level, the question is no longer how to write them, but how to predict exactly how the compiler evaluates them, especially when unions and never are involved.

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

type A = IsString<"hello">;
// true

type B = IsString<string | number>;
// boolean, not false!
A surprising result on a union: boolean.

The key behavior is distributivity: when the tested type is a naked type parameter (not wrapped in a tuple, an array, or an intersection), the conditional is applied separately to each member of the union, and the results are then combined. IsString<string | number> therefore computes true | false, which is boolean. To disable this distribution, wrap both sides in a tuple: [T] extends [U]. One fearsome edge case: never is the empty union; distributing over zero members evaluates no branch at all, and the result is never no matter which branch you wrote.

TYPESCRIPT
type ToArray<T> = T extends unknown ? T[] : never;
type NoDist<T> = [T] extends [unknown] ? T[] : never;

type A = ToArray<string | number>;
// string[] | number[]

type B = NoDist<string | number>;
// (string | number)[]

type IsNever<T> = [T] extends [never] ? true : false;
type C = IsNever<never>; // true thanks to the tuple
The tuple neutralizes distribution and makes never reliable.

The infer keyword introduces a type variable captured through pattern matching: the compiler tries to match the structure of the tested type against the pattern, and binds the variable if the match succeeds. You can place several infer declarations with the same name: in a covariant position (produced values, such as a return type), the candidates are merged into a union; in a contravariant position (function parameters), they are combined into an intersection. This rule follows directly from variance: it is the only safe choice in each direction.

TYPESCRIPT
type ElementOf<T> =
  T extends readonly (infer E)[] ? E : never;

type FirstParam<T> =
  T extends (arg: infer P) => unknown ? P : never;

type Merged<T> =
  T extends { a: (x: infer P) => void;
              b: (x: infer P) => void }
    ? P : never;

type M = Merged<{
  a: (x: { id: string }) => void;
  b: (x: { tag: string }) => void;
}>; // { id: string } & { tag: string }
Two infer in contravariant position: intersection.

Since TypeScript 4.7, infer accepts an inline constraint: infer N extends number filters the capture, and since 4.8 the compiler even converts a numeric string literal into a number literal. Another specification subtlety: a conditional type whose input still contains an unresolved generic parameter stays deferred; it is only evaluated at the instantiation site, which explains why some errors only show up on the caller's side.

TYPESCRIPT
type ToNumber<S extends string> =
  S extends `${infer N extends number}` ? N : never;

type Port = ToNumber<"8080">; // the literal 8080
type Bad = ToNumber<"abc">;   // never
Constraint on infer and literal conversion (4.8).

Knowledge check

Make sure you remember the key points of this lesson.

  1. What is the type Exclude<boolean, true>?
    • boolean
    • false
    • never
    • true
  2. With type IsNever<T> = T extends never ? true : false;, what is IsNever<never>?
    • true
    • false
    • never
    • boolean
  3. Two infer P with the same name placed in function parameter position produce:
    • a union of the candidates
    • an intersection of the candidates
    • the first candidate encountered
    • a compilation error