TypeScript Conditional Types: Infer and Extends
Conditional types bring if-else logic to TypeScript's type system. They follow a ternary-like syntax: `T extends U ? X : Y`. This reads as 'if type T is assignable to type U, then the type is X,...
Key Insights
- Conditional types use
T extends U ? X : Ysyntax to create type-level branching logic, enabling sophisticated type transformations based on structural compatibility checks - The
inferkeyword acts as a pattern-matching tool that extracts and captures type information from within conditional type checks, making it possible to pull out function return types, array elements, and other nested type structures - Conditional types distribute over union types by default, which means
string | number extends any ? true : falseevaluates each union member separately—wrap types in tuples[T]to prevent distribution when needed
Introduction to Conditional Types
Conditional types bring if-else logic to TypeScript’s type system. They follow a ternary-like syntax: T extends U ? X : Y. This reads as “if type T is assignable to type U, then the type is X, otherwise it’s Y.”
This mechanism is crucial for building type-safe APIs that adapt based on input types. Instead of using function overloads or type assertions, conditional types let the compiler infer the correct return type automatically.
Here’s a simple conditional type that checks if a type is an array:
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // false
type C = IsArray<[1, 2, 3]>; // true
The extends keyword checks if T is structurally compatible with any[]. If it is, the type resolves to true, otherwise false.
Understanding the extends Keyword in Type Context
In conditional types, extends performs a type compatibility check. It asks: “Can type T be assigned to type U?” This is different from class inheritance or generic constraints, though they share the same keyword.
The check is structural, not nominal. TypeScript only cares about the shape of the type:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<string>; // true
type C = IsString<string | number>; // boolean (distributes!)
That third example reveals an important behavior: conditional types distribute over unions. When you pass a union type to a conditional, TypeScript evaluates each member separately and unions the results:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Evaluates as: ToArray<string> | ToArray<number>
// Result: string[] | number[]
This distribution happens automatically for naked type parameters (type parameters not wrapped in another type). To prevent distribution, wrap the type in a tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]
The infer Keyword: Extracting Types
The infer keyword is where conditional types become truly powerful. It lets you extract and name types within the extends clause. Think of it as pattern matching for types.
You can only use infer within the extends portion of a conditional type. It declares a type variable that TypeScript will infer from the structure being checked.
Extracting function return types:
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Alice" };
}
type User = GetReturnType<typeof getUser>;
// User: { id: number; name: string; }
Here, infer R captures whatever type the function returns. If T matches the function signature pattern, TypeScript infers R and returns it. Otherwise, the type is never.
Extracting array element types:
type ElementType<T> = T extends (infer E)[] ? E : never;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<(string | number)[]>; // string | number
Extracting Promise resolved types:
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<number>>; // number
type C = Awaited<string>; // string (not a Promise)
This pattern is so useful that TypeScript includes a built-in Awaited type that handles nested Promises recursively.
Practical Patterns and Use Cases
Let’s build some practical utility types using infer and extends.
Custom ReturnType and Parameters:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number) {
return { name, age, id: Math.random() };
}
type Return = MyReturnType<typeof createUser>;
// { name: string; age: number; id: number; }
type Params = MyParameters<typeof createUser>;
// [name: string, age: number]
Notice that Parameters infers a tuple type representing the function’s parameter list.
Unwrapping nested types:
type Unpromisify<T> = T extends Promise<infer U>
? U
: T;
type UnwrapArray<T> = T extends Array<infer U>
? U
: T;
type A = Unpromisify<Promise<string>>; // string
type B = UnwrapArray<Array<number>>; // number
type C = Unpromisify<UnwrapArray<Promise<string[]>>>; // string[]
Flattening nested arrays:
type Flatten<T> = T extends Array<infer U>
? U extends Array<any>
? Flatten<U>
: U
: T;
type A = Flatten<number[]>; // number
type B = Flatten<number[][]>; // number
type C = Flatten<number[][][]>; // number
This recursive conditional type keeps unwrapping array layers until it hits a non-array type.
Advanced Techniques
Multiple infer declarations:
You can use multiple infer keywords in a single conditional type to extract multiple pieces of information:
type FirstAndLast<T extends any[]> = T extends [infer First, ...any[], infer Last]
? [First, Last]
: never;
type A = FirstAndLast<[1, 2, 3, 4, 5]>; // [1, 5]
type B = FirstAndLast<['a', 'b']>; // ['a', 'b']
type C = FirstAndLast<[1]>; // never
Deep property access:
type DeepProp<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepProp<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never;
type User = {
profile: {
address: {
city: string;
}
}
};
type City = DeepProp<User, "profile.address.city">; // string
This combines template literal types with conditional types to traverse nested object structures at the type level.
Tuple to union conversion:
type TupleToUnion<T extends any[]> = T extends Array<infer E> ? E : never;
type A = TupleToUnion<[string, number, boolean]>;
// string | number | boolean
When you infer an array element type from a tuple, TypeScript gives you a union of all element types.
Common Pitfalls and Best Practices
Distribution behavior surprises:
The most common mistake is forgetting that conditional types distribute over unions. This can lead to unexpected results:
type Wrap<T> = T extends any ? { value: T } : never;
type A = Wrap<string | number>;
// { value: string } | { value: number }
// NOT { value: string | number }
If you want to wrap the entire union, use the tuple trick:
type WrapUnion<T> = [T] extends [any] ? { value: T } : never;
type B = WrapUnion<string | number>;
// { value: string | number }
When not to use infer:
Don’t reach for infer when simpler type operations work. For accessing object properties, use indexed access types:
// Unnecessary:
type GetId<T> = T extends { id: infer I } ? I : never;
// Better:
type GetId<T> = T extends { id: any } ? T['id'] : never;
// Best:
type GetId<T> = T['id']; // Use with proper constraints
Performance considerations:
Complex recursive conditional types can slow down the TypeScript compiler. The compiler has recursion depth limits (usually around 50 levels). For deeply nested structures, consider alternative approaches or simplify your types.
// This will hit recursion limits on very deep arrays:
type DeepFlatten<T> = T extends Array<infer U>
? DeepFlatten<U>
: T;
TypeScript 4.5+ has better tail-call optimization for recursive types, but it’s still worth keeping type complexity in check.
Prefer built-in utilities:
TypeScript provides many utility types that use conditional types internally: ReturnType, Parameters, Awaited, NonNullable, Extract, and Exclude. Use these instead of rolling your own—they’re well-tested and optimized.
Conditional types with infer and extends are essential tools for advanced TypeScript development. They enable type-level programming that makes impossible-to-misuse APIs and catch errors at compile time. Master these patterns, and you’ll write more maintainable, type-safe code.