Conditional types in TypeScript
Conditional types in TypeScript select one of two types based on whether T is assignable to U, using T extends U ? X : Y syntax that runs entirely at compile time.
Theory
TL;DR
- Syntax:
T extends U ? X : Y. T fits U, you get X. Otherwise you get Y. - Think vending machine: insert a type, the machine checks if it matches, dispenses one result.
- Main difference from unions: conditional types distribute over each union member separately when T is a bare generic.
infercaptures a sub-type during the check:T extends Promise<infer U> ? U : T.- Built on this:
ReturnType,Exclude,Extract,NonNullable,Parameters.
Quick example
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hi">; // "yes" -- literal "hi" is a subtype of stringT extends string tests structural assignability, not identity. String literals pass because they are subtypes of string.
Key difference from unions
A union like string | number lists possibilities. A conditional type runs a check and produces one specific result. That is why ReturnType<T> works but no union can replicate it: you need to test T extends (...args: any[]) => infer R to extract R. Unions have no mechanism for that.
When to use
- Extract a function's return type or parameter types -> use
infer. - Filter union members by removing matches ->
T extends SomeType ? never : T(that isExcludeexactly). - Apply a transformation only to object types ->
T extends object ? DeepPartial<T> : T. - Build a utility type that branches on shape instead of writing 3-4 manual overloads.
If a plain union or intersection does the job, skip the conditional. Not every type problem needs a ternary.
Distribution over unions
When you pass a union to a conditional type with a bare generic T, TypeScript splits the union and runs the condition per member:
type Wrap<T> = T extends any ? [T] : never;
type A = Wrap<string | number>;
// Runs as: Wrap<string> | Wrap<number>
// Result: [string] | [number]If T is wrapped in brackets, an array, or another generic, distribution stops:
type WrapFixed<T> = [T] extends [any] ? T[] : never;
type B = WrapFixed<string | number>;
// Result: (string | number)[] -- treated as one typeWrapping in [T] is the standard way to opt out of distribution.
The infer keyword
infer declares a type variable inside the condition and lets TypeScript capture whatever type fits that position:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number -- passes through unchangedStack infer to pull out tuple heads or function params:
type Head<T> = T extends [infer First, ...infer Rest] ? First : never;
type H = Head<[string, number, boolean]>; // stringHow the compiler resolves this
TypeScript tests assignability structurally in a single-pass resolution. For distributed conditionals, it runs the condition once per union member when T is in a bare generic position. Zero runtime cost. The whole mechanism compiles away to plain JavaScript.
One subtlety: if T is still unresolved inside a generic function body, the compiler keeps the conditional unevaluated until the call site provides a concrete type.
Common mistakes
Expecting distribution when T is not bare
// Distributes -- T is naked here
type Id<T> = T extends any ? T : never;
type X = Id<string | number>; // string | number
// Does NOT distribute -- T is wrapped in Array<>
type Wrapped<T> = Array<T> extends any ? T : never;
type Y = Wrapped<string | number>; // string | number -- no splitDistribution only triggers when the type being tested is the naked generic T, not T wrapped in something else.
Using any in the condition widens literals
// Bad -- "a" widens to string
type ToArray<T> = T extends any ? T[] : never;
type Leak = ToArray<"a" | "b">; // string[]
// Good -- literals preserved
type ToArraySafe<T> = T extends unknown ? T[] : never;
type Fixed = ToArraySafe<"a" | "b">; // "a"[] | "b"[]unknown preserves literal types. any triggers fresh inference and often widens them.
Forgetting that never extends U is always true
type A = never extends string ? true : false; // truenever is a subtype of every type, so it passes any extends check. Union members that evaluate to never drop out of the result automatically. That is usually the intended behavior in filters like Exclude, but it surprises people the first time they see it.
Real-world usage
- React Query:
UseQueryResult<TData>uses conditional inference to unwrap async data types. - Zod:
z.infer<typeof schema>resolves throughT extends z.ZodType ? T['_output'] : never. - tRPC: procedure types branch on
TRPCErrorto build typedResult<Success, Failure>wrappers. - TypeScript stdlib:
Exclude,Extract,NonNullable,ReturnType,Parametersare all conditional types under the hood.
Follow-up questions
Q: Write a conditional type that extracts all parameter types of a function.
A: type Params<T> = T extends (...args: infer P) => any ? P : never; Here P captures the full parameter tuple, so Params<(a: string, b: number) => void> gives [string, number].
Q: What is the difference between T extends U ? X : Y and [T] extends [U] ? X : Y?
A: The bracket version disables distribution. Without brackets, a union T splits and the condition runs per member. With brackets, T is treated as one type regardless of whether it is a union.
Q: Why does never extends string evaluate to true?
A: never is the bottom type, a subtype of everything. So it passes any extends check. When filtering a union, members that become never fall out of the result, which is exactly the behavior Exclude and NonNullable rely on.
Q: How do conditional types interact with mapped types?
A: You can combine them to filter object keys. type Filter<T, U> = { [K in keyof T]: T[K] extends U ? T[K] : never } keeps only values assignable to U. Append [keyof T] to collapse the object to a union of matching values.
Q: (Senior) Why do both T extends any and T extends unknown distribute, but [T] extends [any] does not?
A: Distribution is triggered by the position of bare T in the condition, not by what T is tested against. Wrapping T in [T] changes its position from bare generic to tuple member. That single structural change stops the distribution mechanism entirely.
Examples
Extracting return type with infer
The pattern behind TypeScript's built-in ReturnType<T>:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
async function fetchUser(): Promise<{ id: number; name: string }> {
return { id: 1, name: "Alice" };
}
type FetchResult = MyReturnType<typeof fetchUser>;
// Promise<{ id: number; name: string }>
// Combine with Awaited to unwrap the promise:
type UserData = Awaited<FetchResult>;
// { id: number; name: string }infer R captures whatever the function returns. If T is not callable, the result is never.
Filtering a union with distribution
type OnlyStrings<T> = T extends string ? T : never;
type Mixed = "admin" | "user" | 42 | true;
type StringRoles = OnlyStrings<Mixed>; // "admin" | "user"TypeScript runs OnlyStrings on each union member separately. Number and boolean become never and drop out of the final union. This is exactly how Extract<T, string> works internally.
Recursive DeepPartial
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
interface Config {
server: {
host: string;
port: number;
};
debug: boolean;
}
type PartialConfig = DeepPartial<Config>;
// {
// server?: { host?: string; port?: number };
// debug?: boolean;
// }The condition T extends object splits objects from primitives. Primitives pass through unchanged; objects get recursively wrapped. I have used this pattern when building API clients where patch endpoints accept updates at any nesting depth, without defining a separate partial type for each nested interface.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.