5.4 Conditional Types
What Are Conditional Types?
Conditional types express if-else logic at the type level. The result type changes depending on the input type.
// Basic syntax
type ConditionalType = T extends U ? X : Y;
// ^^^^^^^^^^^^^^^^
// "If T is assignable to U, the result is X, otherwise Y"
The syntax resembles JavaScript's ternary operator, but it operates on types rather than values.
// Simple example
type IsString<T> = T extends string ? "yes" : "no";
type Test1 = IsString<string>; // "yes"
type Test2 = IsString<number>; // "no"
type Test3 = IsString<"hello">; // "yes" (string literal is a subtype of string)
Basic Syntax and Usage
Branching on Types
// Check for nullability
type IsNullable<T> = null extends T ? true : false;
type A = IsNullable<string | null>; // true
type B = IsNullable<string>; // false
type C = IsNullable<null>; // true
// Check whether a type is an array
type IsArray<T> = T extends unknown[] ? true : false;
type D = IsArray<number[]>; // true
type E = IsArray<string>; // false
type F = IsArray<never[]>; // true
type G = IsArray<readonly number[]>; // false (readonly array is not a subtype of unknown[])
// Check whether a type is a function
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;
type H = IsFunction<() => void>; // true
type I = IsFunction<(x: number) => string>; // true
type J = IsFunction<string>; // false
Type Transformation with Conditional Types
// Return the element type if it's an array; otherwise return the type as-is
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type Num = Flatten<number[]>; // number
type Str = Flatten<string>; // string (not an array → unchanged)
type Nested = Flatten<string[][]>; // string[] (only one level flattened)
// Remove null and undefined
type NonNullable2<T> = T extends null | undefined ? never : T;
type K = NonNullable2<string | null | undefined>; // string
type L = NonNullable2<number | null>; // number
Distributive Conditional Types
When a union type is passed to a conditional type, the type is automatically distributed over each member. This is the key behavior of distributive conditional types.
type IsString<T> = T extends string ? "yes" : "no";
// Distribution over a union type
type Test = IsString<string | number | boolean>;
// = IsString<string> | IsString<number> | IsString<boolean>
// = "yes" | "no" | "no"
// = "yes" | "no"
For distribution to occur, the type parameter T must be a "naked type parameter" — the generic type parameter must appear directly before extends.
// Distributive conditional types — how Exclude and Extract work
type MyExclude<T, U> = T extends U ? never : T;
type Result1 = MyExclude<"a" | "b" | "c" | "d", "b" | "d">;
// = MyExclude<"a", "b" | "d"> // "a"
// | MyExclude<"b", "b" | "d"> // never
// | MyExclude<"c", "b" | "d"> // "c"
// | MyExclude<"d", "b" | "d"> // never
// = "a" | never | "c" | never
// = "a" | "c"
type MyExtract<T, U> = T extends U ? T : never;
type Result2 = MyExtract<"admin" | "user" | "guest", "admin" | "moderator">;
// = "admin"
Leveraging Distribution for Type Transformations
// Wrap each union member
type Wrap<T> = T extends any ? { value: T } : never;
type Wrapped = Wrap<string | number>;
// = { value: string } | { value: number }
// Convert each union member to an array
type ToArray<T> = T extends any ? T[] : never;
type ArrayUnion = ToArray<string | number>;
// = string[] | number[] (not (string | number)[])
// Convert to function types
type ToFunction<T> = T extends any ? () => T : never;
type FunctionUnion = ToFunction<string | number | boolean>;
// = (() => string) | (() => number) | (() => boolean)
The infer Keyword
infer infers a type inside a conditional type and assigns it a name. infer R means "infer the type here and call it R."
// Basic infer usage
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;
type N = UnpackArray<number[]>; // number
type S = UnpackArray<string[]>; // string
type M = UnpackArray<string[][]>; // string[] (one level)
// Extract function return type
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
// Extract function parameter types
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type GreetParams = MyParameters<typeof greet>; // [name: string]
// Extract only the first parameter
type FirstParameter<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
function createUser(name: string, age: number, email: string): void {}
type FirstArg = FirstParameter<typeof createUser>; // string
Unwrapping Promises
// Extract T from Promise<T>
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type P1 = UnpackPromise<Promise<string>>; // string
type P2 = UnpackPromise<Promise<number[]>>; // number[]
type P3 = UnpackPromise<string>; // string (not a Promise)
// Recursively unwrap nested Promises
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type P4 = DeepUnpackPromise<Promise<Promise<Promise<string>>>>;
// string
// In TypeScript 4.5+ use the built-in Awaited<T>
type P5 = Awaited<Promise<Promise<string>>>; // string
Extracting Types from Tuples
// Type of the first element
type Head<T extends unknown[]> = T extends [infer H, ...any[]] ? H : never;
type H1 = Head<[string, number, boolean]>; // string
type H2 = Head<[number]>; // number
type H3 = Head<[]>; // never
// Type of the last element
type Last<T extends unknown[]> = T extends [...any[], infer L] ? L : never;
type L1 = Last<[string, number, boolean]>; // boolean
type L2 = Last<[number]>; // number
// Tail (everything except the first element)
type Tail<T extends unknown[]> = T extends [any, ...infer Rest] ? Rest : never;
type T1 = Tail<[string, number, boolean]>; // [number, boolean]
type T2 = Tail<[string]>; // []
Nested Conditional Types
// Classify types into multiple branches
type TypeCategory<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends null ? "null" :
T extends undefined ? "undefined" :
T extends any[] ? "array" :
T extends object ? "object" :
"unknown";
type C1 = TypeCategory<string>; // "string"
type C2 = TypeCategory<42>; // "number"
type C3 = TypeCategory<boolean>; // "boolean"
type C4 = TypeCategory<null>; // "null"
type C5 = TypeCategory<number[]>; // "array"
type C6 = TypeCategory<{ a: 1 }>; // "object"
// Branch based on whether a property is readonly
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type IsReadonly<T, K extends keyof T> =
({ [P in K]: T[P] } extends { readonly [P in K]: T[P] }
? true
: false);
Disabling Distribution
When you do not want union distribution, wrap the type in a tuple: [T] extends [U].
// Default behavior: distribution occurs
type IsNever<T> = T extends never ? true : false;
type Test1 = IsNever<never>; // never (condition is never evaluated because of distribution)
type Test2 = IsNever<string>; // false
// Fixed: disable distribution
type IsNeverFixed<T> = [T] extends [never] ? true : false;
type Test3 = IsNeverFixed<never>; // true (correct)
type Test4 = IsNeverFixed<string>; // false
// Detecting union types without distribution
type IsUnion<T> = [T] extends [never]
? false
: T extends any
? ([Exclude<T, T>] extends [never] ? false : true)
: never;
// Why wrap in a tuple?
// When T = string | number:
// T extends U → string extends U | number extends U (distributed)
// [T] extends [U] → [string | number] extends [U] (no distribution)
// Practical example of disabling distribution
type Distribute<T, U> = T extends U ? T[] : never;
type NoDistribute<T, U> = [T] extends [U] ? T[] : never;
type D1 = Distribute<string | number, string>;
// string[] | never = string[] (distributed)
type D2 = NoDistribute<string | number, string>;
// never (string | number is not a subtype of string)
Practical Examples
IsArray<T>
type IsArray<T> = T extends readonly unknown[] ? true : false;
type R1 = IsArray<number[]>; // true
type R2 = IsArray<readonly string[]>; // true
type R3 = IsArray<[1, 2, 3]>; // true (tuples are arrays)
type R4 = IsArray<string>; // false
type R5 = IsArray<{ length: number }>; // false
UnpackPromise<T>
// Recursively remove all Promise layers
type UnpackPromise<T> = T extends Promise<infer Inner>
? UnpackPromise<Inner>
: T;
type U1 = UnpackPromise<Promise<string>>; // string
type U2 = UnpackPromise<Promise<Promise<number[]>>>; // number[]
type U3 = UnpackPromise<string>; // string
// Combined with a real function
async function fetchData(): Promise<{ id: number; name: string }> {
return { id: 1, name: "Alice" };
}
type FetchResult = UnpackPromise<ReturnType<typeof fetchData>>;
// { id: number; name: string }
FunctionReturnType<T>
// Extract the return type, unwrapping Promise for async functions
type FunctionReturnType<T extends (...args: any[]) => any> =
ReturnType<T> extends Promise<infer R>
? R
: ReturnType<T>;
async function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
function getVersion(): string {
return "1.0.0";
}
type UserType = FunctionReturnType<typeof fetchUser>; // User (Promise removed)
type VersionType = FunctionReturnType<typeof getVersion>; // string
DeepRequired<T>
// Make all nested properties required
type DeepRequired<T> = T extends object
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
: T;
interface Profile {
name?: string;
address?: {
street?: string;
city?: string;
zip?: string;
country?: {
code?: string;
name?: string;
};
};
preferences?: {
theme?: "light" | "dark";
language?: string;
};
}
type StrictProfile = DeepRequired<Profile>;
// {
// name: string;
// address: {
// street: string;
// city: string;
// zip: string;
// country: { code: string; name: string; };
// };
// preferences: { theme: "light" | "dark"; language: string; };
// }
Type Filtering
// Extract union members that match a specific structure
type FilterByProp<T, K extends string, V> =
T extends Record<K, V> ? T : never;
type Actions =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "REMOVE_TODO"; payload: { id: number } }
| { type: "CLEAR_ALL" }
| { type: "SET_FILTER"; payload: { filter: string } };
// Extract only actions that have a payload
type ActionsWithPayload = FilterByProp<Actions, "payload", unknown>;
// { type: "ADD_TODO"; payload: ... }
// | { type: "REMOVE_TODO"; payload: ... }
// | { type: "SET_FILTER"; payload: ... }
// Extract an action by its type value
type ExtractAction<T extends { type: string }, K extends T["type"]> =
T extends { type: K } ? T : never;
type AddTodoAction = ExtractAction<Actions, "ADD_TODO">;
// { type: "ADD_TODO"; payload: { text: string } }
Pro Tips
Debugging Conditional Types
// Break down complex conditional types step by step
type Complex<T> =
T extends string
? T extends `${infer Start}${infer End}`
? [Start, End]
: never
: never;
// Debugging: isolate each step
type Step1<T> = T extends string ? T : never; // only strings pass
type Step2<T extends string> = T extends `${infer S}${infer E}` ? [S, E] : never;
// Useful pattern for inspecting types in the IDE
type Debug<T> = { [K in keyof T]: T[K] }; // force type expansion
type DebugComplex = Debug<Complex<"hello">>;
// Tracing never
type TraceNever<T> =
[T] extends [never]
? "is never"
: `is type ${string & T}`;
type T1 = TraceNever<never>; // "is never"
type T2 = TraceNever<string>; // "is type string"
Breaking Down Complex Types into Meaningful Steps
// Bad pattern: expressing everything in one go
type OverlyComplex<T> =
T extends Array<infer E>
? E extends string
? E extends `${infer P}:${infer Q}`
? { prefix: P; suffix: Q }[]
: string[]
: E extends number
? number[]
: never
: never;
// Good pattern: split into meaningful named pieces
type ElementOf<T> = T extends Array<infer E> ? E : never;
type ParseColonString<S extends string> =
S extends `${infer P}:${infer Q}` ? { prefix: P; suffix: Q } : never;
type ParseStringArray<T extends string[]> = {
[K in keyof T]: T[K] extends string ? ParseColonString<T[K]> : never;
};
// Each step can be tested independently
type E = ElementOf<string[]>; // string
type P = ParseColonString<"key:value">; // { prefix: "key"; suffix: "value" }
Using never for Exhaustiveness Checks
// Verify that all union cases have been handled
type Exhaustive<T extends never> = T;
function handleAction(action: Actions): string {
switch (action.type) {
case "ADD_TODO":
return `Add: ${action.payload.text}`;
case "REMOVE_TODO":
return `Remove: ${action.payload.id}`;
case "CLEAR_ALL":
return "Clear all";
case "SET_FILTER":
return `Set filter: ${action.payload.filter}`;
default:
// Once all cases are handled, action is never
const exhausted: Exhaustive<typeof action> = action;
return exhausted;
}
}
// Adding a new type to Actions triggers a compile error in the switch above
Summary Table
| Concept | Syntax | Description |
|---|---|---|
| Basic conditional type | T extends U ? X : Y | X if T is assignable to U |
| Distributive conditional | Automatically applied to union T | Each member is evaluated individually |
| Disable distribution | [T] extends [U] | Wrap in a tuple to prevent distribution |
| Basic infer | T extends F<infer R> | Infer and name a type inside a condition |
| Extract array element | T extends Array<infer E> | Extract the element type of an array |
| Extract return type | T extends (...) => infer R | Extract the return type |
| Unwrap Promise | T extends Promise<infer U> | Extract the inner type of a Promise |
| Recursive conditional | Self-referencing conditional type | Handle nested structures |
| never distribution | T extends never → never | Work around with [T] extends [never] |
Up Next...
Section 5.5 covers Generic Patterns in Practice. You will implement real-world design patterns with generics — Repository pattern, Builder pattern, API response wrappers, React generic hooks, and a generic event emitter — and learn how to design flexible APIs while maintaining type safety throughout.