Chapter 8

Conditional Types and infer: Extracting Subtypes

Basic Syntax: T extends U ? X : Y

Conditional types look like a ternary expression, and reading them works the same way: "if T is assignable to U, the result is X; otherwise it is Y".

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

type A = IsString<string>;  // true
type B = IsString<number>;  // false
type C = IsString<"hello">; // true — string literals satisfy extends string

Important: this is a compile-time type computation, not runtime code. extends in a conditional type means assignability: the condition is true if every value of type T could legally be assigned to a variable of type U.

// Nested conditionals: multi-branch type discrimination
type TypeName<T> =
  T extends string    ? "string"    :
  T extends number    ? "number"    :
  T extends boolean   ? "boolean"   :
  T extends null      ? "null"      :
  T extends undefined ? "undefined" :
  "object";

type R1 = TypeName<string>;   // "string"
type R2 = TypeName<42>;       // "number"
type R3 = TypeName<boolean>;  // "boolean"
type R4 = TypeName<string[]>; // "object"

Distributive Conditional Types: Automatic Union Expansion

When T is a bare type parameter (not wrapped in any structure) and appears in T extends U ? X : Y, TypeScript evaluates the condition separately for each member of a union, then reunites the results.

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

// T is a union — the conditional type distributes over each member
type R = IsString<string | number | boolean>;
// equivalent to: IsString<string> | IsString<number> | IsString<boolean>
// equivalent to: true | false | false
// simplifies to: boolean

This is the mechanism behind Exclude and Extract:

// How Exclude<T, U> works
type Exclude<T, U> = T extends U ? never : T;

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

Distribution only fires when:


Preventing Distribution: [T] extends [U]

Sometimes you want the union evaluated as a whole, not distributed. Wrap both sides in a single-element tuple:

// Distributive: the union is split apart
type IsStringDistributive<T> = T extends string ? true : false;
type D = IsStringDistributive<string | number>; // boolean (true | false)

// Non-distributive: the whole union is judged as a unit
type IsStringNonDistributive<T> = [T] extends [string] ? true : false;
type ND = IsStringNonDistributive<string | number>; // false (the union as a whole doesn't extend string)

// Practical use: checking for never
type IsNever<T> = [T] extends [never] ? true : false;

type N1 = IsNever<never>;  // true
type N2 = IsNever<string>; // false

// Why can't we use the distributive version?
type BrokenIsNever<T> = T extends never ? true : false;
type Broken = BrokenIsNever<never>; // never — not true!
// Reason: a never union has no members to distribute over, so the result is also never

The infer Keyword: Capturing a Type from a Pattern

infer is the key capability that makes conditional types powerful. Inside the extends clause, you can replace a type position with infer X to capture whatever type appears there and use it in the result branch.

Extract a Function's Return Type

// Re-implementing ReturnType from scratch
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function greet(name: string): string {
  return `Hello, ${name}`;
}

type GreetReturn = MyReturnType<typeof greet>; // string

// Non-function types produce never
type NotFn = MyReturnType<string>; // never

Extract a Promise's Value Type

type UnpackPromise<T> = T extends Promise<infer V> ? V : T;

type A = UnpackPromise<Promise<string>>;   // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (not a Promise — returned as-is)

Extract Array Element Type

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

type N = ElementType<number[]>;            // number
type S = ElementType<string[]>;            // string
type U = ElementType<(string | number)[]>; // string | number
type X = ElementType<string>;              // never (not an array)

// More general version — also handles ReadonlyArray
type ElementType<T> = T extends readonly (infer E)[] ? E : never;

const arr = [1, 2, 3] as const;
type E = ElementType<typeof arr>; // 1 | 2 | 3 (literal type union)

Extract Function Parameter Types

type FunctionArgs<T> = T extends (...args: infer P) => any ? P : never;

function login(username: string, password: string, remember: boolean): void {}

type Args = FunctionArgs<typeof login>;
// [username: string, password: string, remember: boolean]

// Extract only the first parameter
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type First = FirstArg<typeof login>; // string

// Extract only the last parameter
type LastArg<T> = T extends (...args: infer P) => any
  ? P extends [...any[], infer L] ? L : never
  : never;
type Last = LastArg<typeof login>; // boolean

Building Awaited<T> Step by Step (Recursive Conditional Types)

TypeScript 4.5 includes Awaited<T> in the standard library, but building it yourself is the best way to understand recursive conditional types:

// Version 1: unwrap only one level
type Awaited1<T> = T extends Promise<infer V> ? V : T;

type A1 = Awaited1<Promise<string>>;         // string
type A2 = Awaited1<Promise<Promise<string>>>; // Promise<string> — only one level unwrapped!

// Version 2: recursive — handles any nesting depth
type Awaited2<T> = T extends Promise<infer V> ? Awaited2<V> : T;

type A3 = Awaited2<Promise<string>>;                   // string
type A4 = Awaited2<Promise<Promise<string>>>;           // string
type A5 = Awaited2<Promise<Promise<Promise<number>>>>;  // number

// Version 3: handle thenable objects (mirrors the stdlib implementation)
type Awaited3<T> =
  T extends null | undefined ? T :
  T extends object & { then(onfulfilled: infer F, ...args: any[]): any } ?
    F extends (value: infer V, ...args: any[]) => any ?
      Awaited3<V> :
      never :
  T;

Notes on recursive types:

// TypeScript limits recursion depth
// Excessively deep recursion causes: "Type instantiation is excessively deep" error

// DeepReadonly works because object nesting is bounded by real data structures
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// A recursive JSON type definition
type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

never in Conditional Types: Filtering Union Members

never is the zero element of union types — adding never to any union has no effect. This property makes never the perfect filter mechanism:

// Keep only union members that match a constraint
type FilterString<T, U extends string> = T extends U ? T : never;

type Colors = "red" | "green" | "blue" | "yellow";
type WarmColors = FilterString<Colors, "red" | "yellow">; // "red" | "yellow"

// Practical: extract the keys of an object whose values are of a specific type
type KeysOfType<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  active: boolean;
}

type StringKeys = KeysOfType<User, string>;  // "name" | "email"
type NumberKeys = KeysOfType<User, number>;  // "id" | "age"

How KeysOfType works step by step:

// Step 1: mapped type — preserve the key name if the value matches, otherwise never
{
  [K in keyof User]: User[K] extends string ? K : never
}
// expands to:
// {
//   id: never;
//   name: "name";
//   email: "email";
//   age: never;
//   active: never;
// }

// Step 2: index the result with [keyof User] to get a union of all values
// never | "name" | "email" | never | never
// = "name" | "email"

Real-World Utility Types

UnpackPromise<T>

type UnpackPromise<T> = T extends Promise<infer V> ? V : T;

// Useful when working with async function return types
async function fetchUser(id: number) {
  return { id, name: "Alice", email: "[email protected]" };
}

type FetchUserResult = UnpackPromise<ReturnType<typeof fetchUser>>;
// { id: number; name: string; email: string }

// Equivalent using the built-in Awaited:
type FetchUserResult2 = Awaited<ReturnType<typeof fetchUser>>;

ElementType<T>

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

// Extract the element type without knowing the concrete array type
function processFirst<T extends readonly unknown[]>(arr: T): ElementType<T> {
  return arr[0] as ElementType<T>;
}

const nums = [1, 2, 3] as const;
const first = processFirst(nums); // type: 1 (literal type — not number!)

FunctionArgs<T> in Higher-Order Functions

type FunctionArgs<T> = T extends (...args: infer P) => any ? P : never;
type FunctionReturn<T> = T extends (...args: any[]) => infer R ? R : never;

// A memoize wrapper that preserves the full type signature of the original function
function memoize<T extends (...args: any[]) => any>(fn: T): T {
  const cache = new Map<string, FunctionReturn<T>>();
  return ((...args: FunctionArgs<T>) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;
    const result = fn(...args);
    cache.set(key, result);
    return result;
  }) as T;
}

function expensiveCalc(x: number, y: number): number {
  return x * y * Math.PI;
}

const memoCalc = memoize(expensiveCalc);
memoCalc(2, 3); // type: number — identical to the original function
memoCalc("a", 3); // Error: first argument must be number

Anti-Pattern: Deeply Nested Conditional Types

// Anti-pattern: 3+ levels of nesting — hard to read, impossible to debug
type ComplexType<T> =
  T extends string
    ? T extends `${string}Id`
      ? T extends `user${string}`
        ? "userId"
        : "otherId"
      : "string"
    : T extends number
      ? T extends 0
        ? "zero"
        : "number"
      : "other";

// Better: break it into named, single-purpose type aliases
type ExtractIdType<T extends string> =
  T extends `user${string}` ? "userId" : "otherId";

type ExtractStringType<T extends string> =
  T extends `${string}Id` ? ExtractIdType<T> : "string";

type ClassifyType<T> =
  T extends string ? ExtractStringType<T> :
  T extends number ? (T extends 0 ? "zero" : "number") :
  "other";

Best practices for conditional types:

Rule Reasoning
Extract named aliases More than 2 nesting levels → split into separate, named type aliases
Comment intent Document what each branch means semantically
Test your types Use Assert<Equals<...>> to verify edge cases at compile time
Know when to stop If a plain generic constraint would solve the problem, don't reach for conditional types
// Helper: compile-time type assertions for testing
type Assert<T extends true> = T;
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;

// Verify custom Awaited2 behavior — this line errors if any test fails
type Tests = [
  Assert<Equals<Awaited2<Promise<string>>, string>>,
  Assert<Equals<Awaited2<Promise<Promise<number>>>, number>>,
  Assert<Equals<Awaited2<string>, string>>,
];

Summary

Feature Syntax Use Case
Basic conditional type T extends U ? X : Y Type-level conditional logic
Distributive conditional Bare T with union input Filter union members
Prevent distribution [T] extends [U] Judge the whole union as a unit
infer return type T extends (...) => infer R Extract function return type
infer Promise value T extends Promise<infer V> Unwrap Promise
infer array element T extends (infer E)[] Extract element type
infer parameters T extends (...args: infer P) => Extract parameter tuple
Recursive conditional Type alias that references itself Handle arbitrary nesting depth
never as filter Return never to remove union members Precise union filtering

Next chapter: decorators and metaprogramming — attaching compile-time and runtime metadata to classes, methods, and properties.

Rate this chapter
4.8  / 5  (44 ratings)

💬 Comments