Type-Level Logic

Conditional types are TypeScript's if/elseat the type level. They make the type of one thing depend on the type of another โ€” and they're behind every advanced utility type in the standard library. Understanding them means you can read (and write) the types that previously looked like compiler magic.

๐Ÿค” Sound familiar?
  • You see T extends U ? X : Y in the TypeScript source and skip past it
  • You copy infer patterns without understanding how the capture works
  • You hit the distributive behaviour by accident and get a union when you expected a single type
  • You want a type that returns different output types based on input โ€” and don't know how to express it

Conditional types let you encode runtime branching at the type level โ€” the basis of overloads that actually compose.

The Basic Form

// T extends U ? TrueType : FalseType
// Reads: "If T is assignable to U, resolve to TrueType, otherwise FalseType"

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

type A = IsString<string>;  // true
type B = IsString<number>;  // false
type C = IsString<'hello'>; // true โ€” literals extend their base types

// Used inline in complex types:
type Nullable<T> = T extends null | undefined ? never : T;
type NoNull = Nullable<string | null | undefined>; // string

The infer Keyword

infer captures a type from within a pattern match. It only works inside the extends clause of a conditional type.

// Extract the return type (reimplementing ReturnType)
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;

type R1 = ReturnTypeOf<() => string>;         // string
type R2 = ReturnTypeOf<(x: number) => Date>;  // Date
type R3 = ReturnTypeOf<string>;               // never โ€” string is not a function

// Extract element type from Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A1 = Awaited<Promise<string>>;           // string
type A2 = Awaited<string>;                    // string โ€” not a promise, returns T

// Extract element type from array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E1 = ElementOf<string[]>;                // string
type E2 = ElementOf<[number, string]>;        // number | string

// Infer from object property
type ValueOf<T, K extends keyof T> = T extends { [P in K]: infer V } ? V : never;
interface Config { timeout: number; endpoint: string }
type TimeoutType = ValueOf<Config, 'timeout'>; // number

flowchart TD
    ct["T extends Promise&lt;infer U&gt; ? U : T"]
    ct -->|"T = Promise&lt;string&gt;"| match["Matches โ€” U captures string"]
    ct -->|"T = string"| nomatch["No match โ€” returns T (string)"]
    match --> result1["Result: string (unwrapped)"]
    nomatch --> result2["Result: string (passthrough)"]

    style match fill:#16a34a,color:#fff,stroke:#15803d
    style nomatch fill:#6b7280,color:#fff,stroke:#4b5563

Distributive Behaviour

When you apply a conditional type to a naked type parameter (not wrapped in anything), TypeScript distributes over union members one at a time. This is often what you want โ€” but it can surprise you.

type IsString<T> = T extends string ? 'yes' : 'no';

// With a union: distributes over each member
type D1 = IsString<string | number>;
// = IsString<string> | IsString<number>
// = 'yes' | 'no'

// Without distribution โ€” wrap T in a tuple to prevent it
type IsStringNonDistributive<T> = [T] extends [string] ? 'yes' : 'no';
type D2 = IsStringNonDistributive<string | number>;
// = [string | number] extends [string] ? 'yes' : 'no'
// = 'no' โ€” the whole union is checked at once, not distributed

// Practical use: filter union members
type NonNullable<T> = T extends null | undefined ? never : T;
// Distributes: filters null and undefined from the union
type N1 = NonNullable<string | null | undefined>; // string

// The never in a distributed conditional eliminates that branch
type RemoveString<T> = T extends string ? never : T;
type R1 = RemoveString<string | number | boolean>; // number | boolean

Recursive Conditional Types

TypeScript supports recursive conditional types with a depth limit. They enable DeepReadonly, DeepPartial, and deep path extraction.

// DeepReadonly โ€” makes every property read-only, recursively
type DeepReadonly<T> = T extends (infer E)[]
  ? ReadonlyArray<DeepReadonly<E>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface Config {
  server: { host: string; port: number };
  database: { url: string; poolSize: number };
}

type FrozenConfig = DeepReadonly<Config>;
const config: FrozenConfig = getConfig();
// config.server.host = 'new'; // โŒ Error: Cannot assign to readonly property

// Deeply unwrap Promise chains
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type DA = DeepAwaited<Promise<Promise<Promise<string>>>>; // string

Conditional Overloads: Return Type Based on Input

// The problem overloads solve: different return types based on input
function parseInput(input: string): string[];
function parseInput(input: number): number;
function parseInput(input: string | number): string[] | number {
  if (typeof input === 'string') return input.split(',');
  return input * 2;
}

// With generics + conditional types โ€” single declaration
type ParseOutput<T extends string | number> = T extends string ? string[] : number;

function parseGeneric<T extends string | number>(input: T): ParseOutput<T> {
  if (typeof input === 'string') {
    return input.split(',') as ParseOutput<T>;
  }
  return (input * 2) as ParseOutput<T>;
}

const arr = parseGeneric('a,b,c'); // string[]
const num = parseGeneric(5);       // number

Standard Library Conditional Types to Know

// All defined using conditional types internally:

// NonNullable<T> โ€” removes null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;

// Extract<T, U> โ€” keep union members assignable to U
type Extract<T, U> = T extends U ? T : never;

// Exclude<T, U> โ€” remove union members assignable to U
type Exclude<T, U> = T extends U ? never : T;

// ReturnType<T>
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

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

// InstanceType<T>
type InstanceType<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: any) => infer R ? R : any;

Pitfalls

Unexpected distribution with unions

If you're checking whether an entire union is assignable to something, wrap both sides in tuples: [T] extends [string] instead of T extends string. Otherwise the conditional distributes over each member separately.

infer outside conditional types

infer only works inside the extends clause of a conditional type. Using it anywhere else is a syntax error.

Circular conditional types

TypeScript detects simple circular conditional types and errors, but some deep recursive types silently resolve to any. Test recursive types with at least 5 levels of nesting to verify they resolve correctly.