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.
- You see
T extends U ? X : Yin the TypeScript source and skip past it - You copy
inferpatterns 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>; // stringThe 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<infer U> ? U : T"]
ct -->|"T = Promise<string>"| 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 | booleanRecursive 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>>>>; // stringConditional 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); // numberStandard 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.

