Conditional types and infer

TypeScript · Advanced · 15 min

Conditional types power ReturnType, Awaited and most advanced library typings; you need them to read and write those.

A conditional type picks one of two types based on assignability:

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

It's a type-level ternary. Its power comes from two features.

Distribution. When the checked type is a naked type parameter and you instantiate it with a union, the condition is applied to each member separately and the results are unioned. That's how Exclude works:

type Exclude<T, U> = T extends U ? never : T;
// Exclude<"a" | "b" | "c", "a">  →  "b" | "c"

Usually that's what you want, but not always. Wrap both sides in a tuple — [T] extends [U] ? ... — to treat the union as a whole. Also note that never is the empty union, so a distributive conditional over never yields never — a common source of puzzling results.

`infer`. Inside the extends clause you can declare a type variable to capture part of the matched type:

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

This is how ReturnType, Parameters and Awaited are defined. You can constrain an inferred variable directly — infer N extends number — and match several pieces at once, such as the first and rest of a tuple: T extends [infer Head, ...infer Tail].

Conditional types stay deferred while they depend on an unresolved type parameter: inside a generic function, the compiler can't decide T extends string ? A : B until T is known, so returning a value of type A there usually needs an assertion or an overload. That's a known limitation; keep complex conditional types at API boundaries and simple code inside.

Example

type IsString<T> = T extends string ? true : false;
type A = IsString<"hi">;                        // true
type B = IsString<"hi" | 42>;                   // boolean (distributed: true | false)
type C = [string | number] extends [string] ? 1 : 0; // 0 (not distributed)

type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type Unwrap<T> = T extends Promise<infer V> ? Unwrap<V> : T;
type FirstArg<F> = F extends (first: infer P, ...rest: never[]) => unknown ? P : never;
type Head<T extends unknown[]> = T extends [infer H, ...unknown[]] ? H : undefined;
type PortNumber<S> = S extends `${infer N extends number}` ? N : never;

type E = ElementOf<string[]>;                   // string
type U = Unwrap<Promise<Promise<number>>>;      // number
type F = FirstArg<(id: number, name: string) => void>; // number
type H = Head<[boolean, string]>;               // boolean
type P = PortNumber<"8080">;                    // 8080

const checks: [A, B, C, E, U, F, H, P] = [true, false, 0, "x", 1, 2, true, 8080];
console.log(checks);

Remember

  • T extends U ? X : Y is a type-level if/else
  • Naked type parameters distribute over unions; [T] extends [U] prevents it
  • infer X captures part of a matched type
  • Conditional types on unresolved generics stay deferred

Watch out for

  • Being surprised that IsString<never> is never, not false — distribution over the empty union yields nothing.

Check yourself

  1. type T<X> = X extends number ? "n" : "other"; what is T<1 | "a">?
  2. Which definition extracts the resolved type of a promise?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/2/conditional-types.html