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:
Tis a bare type parameter directly on the left ofextends- If T is wrapped (
T[],[T],Promise<T>), distribution does not occur
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.