Conditional Types
A conditional type picks between two types based on a type-level extends check — the type-system equivalent of a ternary expression, evaluated at compile time.
🎯 What you'll learn
- What a Conditional Type Is
- Conditional Types With Generic Functions
- Chained Conditional Types
- Conditional Types With
infer(Preview) - Practical Use: Narrowing a Generic API's Return Type
1What a Conditional Type Is
A conditional type picks between two types based on a type-level extends check — the type-system equivalent of a ternary expression, evaluated at compile time.
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">; // "yes"
type B = IsString<42>; // "no"T extends U here doesn't mean "T is exactly U" — it means "is T assignable to U," the exact same assignability check covered in the Type System Fundamentals topic, just performed at the type level instead of checking a value against a type.
2Conditional Types With Generic Functions
function processValue<T>(value: T): T extends string ? string[] : T[] {
if (typeof value === "string") {
return value.split("") as any; // implementation needs a type assertion — see Common Pitfalls
}
return [value] as any;
}Conditional types let a function's return type genuinely depend on which type its input turned out to be, rather than being fixed for every call.
3Chained Conditional Types
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type A = TypeName<string>; // "string"
type B = TypeName<() => void>; // "function"
type C = TypeName<{ x: 1 }>; // "object"Chaining extends ? : repeatedly builds a type-level if/else-if chain — this pattern mirrors (and is directly inspired by) JavaScript's runtime typeof checks, just operating on types instead of values.
4Conditional Types With infer (Preview)
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number>; // number (not an array, falls through unchanged)infer lets a conditional type capture a piece of the matched type into a new type variable, rather than just branching on true/false — full depth is its own topic (Infer Keyword), introduced here because conditional types and infer are used together constantly in practice.
5Practical Use: Narrowing a Generic API's Return Type
type ApiResult<T> = T extends { error: string } ? never : T;
function unwrap<T>(response: T): ApiResult<T> {
// ...
return response as ApiResult<T>;
}This pattern shows up often in typed API clients: a conditional type expresses "this function's return type depends on whether the input looked like an error shape or not," encoding a real invariant about the function's behavior directly into its signature.
1Distributive Conditional Types — the Subtle Default Behavior
When the type being checked (T) is a naked type parameter (used directly, not wrapped in something like T[] or [T]), and you pass a union as T, the conditional type doesn't run once against the whole union — it runs separately for each member, then unions the results back together.
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// NOT (string | number)[] — instead: string[] | number[]Mechanically, TypeScript evaluates this as if you'd written ToArray<string> | ToArray<number>, then evaluated each piece independently and unioned the results — this automatic per-member iteration is called distribution.
2Why Distribution Is Genuinely Useful
type NonNullable<T> = T extends null | undefined ? never : T;
type Result = NonNullable<string | null | number | undefined>;
// distributes: string extends null|undefined ? never : string → string
// null extends null|undefined ? never : null → never
// number extends null|undefined ? never : number → number
// undefined extends null|undefined ? never : undefined → never
// unions the results: string | never | number | never → simplifies to: string | numberThis is EXACTLY how the real NonNullable<T> utility type (from the Utility Types topic) works — distribution is what lets a single, simple conditional type correctly filter every member of an arbitrary union, without you writing any explicit loop or union-handling logic at all.
3Opting OUT of Distribution
Sometimes you want the conditional type to treat the union as one single thing, not distribute over it. Wrapping both sides in a tuple ([T] instead of bare T) disables distribution, because the type being checked is no longer a "naked" type parameter:
type IsUnion<T> = [T] extends [any] ? false : never; // (illustrative — real IsUnion is trickier)
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Result = ToArrayNonDistributive<string | number>;
// (string | number)[] — the WHOLE union treated as one T, no per-member splittingComparing this against Part 1/section 1's ToArray<string | number> result (string[] | number[]) shows the exact practical difference the tuple wrapping makes.
Under the HoodHow the Checker Actually Evaluates These▾
Conditional types are evaluated lazily — deferred until T is concrete enough
type IsString<T> = T extends string ? "yes" : "no";
function check<T>(value: T): IsString<T> {
// Inside this generic function, T is not yet a concrete type —
// IsString<T> cannot be resolved to "yes" or "no" YET.
// The checker defers evaluation, carrying `IsString<T>` around
// symbolically, until check() is actually CALLED with a real type.
}
check("hello"); // NOW T = string is concrete, and IsString<string> resolves to "yes"This deferral is necessary because the checker cannot know, while still checking the generic definition of check, whether a not-yet-determined T will or won't be assignable to string — resolution genuinely has to wait for instantiation, exactly mirroring how generic type inference itself (Generics topic) only resolves T at each call site, not at the definition site.
Distribution happens by literally iterating the union's constituent list
Recall from the Union Types topic's "Under the Hood" section that a union type is internally represented as a list of constituent types. Distribution over a conditional type is implemented by iterating that exact list: for each constituent, substitute it in for the naked type parameter, evaluate the conditional independently, then collect all the results back into a new union. This is precisely why wrapping in [T] disables it — a tuple [T] is a single, atomic type as far as the naked-parameter check is concerned, so there's no constituent list to iterate at all; the whole union goes in as one piece.
Circular/deeply recursive conditional types get a real recursion depth limit
type Recurse<T> = T extends [infer First, ...infer Rest] ? [First, ...Recurse<Rest>] : [];Patterns like this (common in advanced type-level programming, e.g. tuple manipulation) are genuinely evaluated by the checker step by step, similar in spirit to recursive function evaluation — and, like the Call Stack topic's real recursion limits, the type checker enforces its own instantiation-depth limit (to prevent runaway/infinite type computation from hanging the compiler), producing a "type instantiation is excessively deep" error if a conditional type recurses too far for a given input.
⚠️ Common Pitfalls
- Forgetting distribution happens automatically — a conditional type over a naked type parameter distributes by default; if you wanted the whole union treated as one unit, you must deliberately opt out with the tuple-wrapping trick.
- Distributing over
never—neveris the empty union (a union of zero types); distributing a conditional type overneverproducesneverimmediately, regardless of the conditional's branches — a source of confusing "why did this become never" bugs. - Using a type assertion to paper over an unresolved conditional type inside a generic function body — often necessary in practice (section 2 of Part 1), but understand it's a genuine escape hatch, not a sign the type itself is wrong.
- Hitting the type instantiation depth limit on genuinely deep recursive conditional types — a sign the type-level algorithm needs restructuring (often via
inferand accumulator-style type parameters), not just a compiler quirk to fight around.
📋 Quick Reference
T extends U ? X : Y → type-level ternary, based on assignability
naked T, T = union → conditional DISTRIBUTES over each member automatically
[T] extends [U] ? X : Y → disables distribution — union treated as one unit
generic function body → conditional stays unresolved until called with a concrete T🎤 Interview Q&A
Q1What is a conditional type?core▾
T extends U ? X : Y: chooses a type based on assignability, enabling type-level logic, e.g. type IsString<T> = T extends string ? true : false.
- Used where? Utility types, library typings, API response mapping.
Q2What are distributive conditional types?core▾
When the checked type is a naked type parameter and receives a union, the condition applies to each member separately: Exclude<'a' | 'b', 'a'> gives 'b'. Wrap in brackets ([T] extends [U]) to prevent distribution.
- Why prevent distribution? To check the union as a whole, e.g. detecting never.
Q3How does infer work?core▾
Inside a conditional's extends clause, infer declares a type variable to capture part of a type: type ElementType<T> = T extends (infer E)[] ? E : never.
- Unwrap a promise?
T extends Promise<infer U> ? U : T.
Q4Give a practical conditional type.senior▾
Mapping API endpoints to response types, making function return types depend on arguments (T extends 'json' ? object : string), or extracting props types from components.
- Overloads alternative? Sometimes clearer for users.
Q5What are the downsides of complex conditional types?core▾
Hard-to-read errors, slower compilation, and fragile code. Keep them in library/utility layers, test them with type tests (expectTypeOf), and document them.
- Type testing tools? tsd, expect-type, vitest's expectTypeOf.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.