TypeScript Conditional Types Explained
T extends U ? X : Y, distribution over unions, and the infer keyword — the three rules that build every advanced TypeScript utility.
What you'll learn
- ✓The T extends U ? X : Y syntax
- ✓How conditional types distribute over unions
- ✓The infer keyword and where it lives
- ✓How to write Unpromise<T>, ReturnType, and friends
- ✓Pitfalls — and how to opt out of distribution
Prerequisites
- •Comfortable with TypeScript generics and basic utility types — see TypeScript Utility Types
Conditional types are the closest thing TypeScript has to control flow at the type level. Once you understand the three rules — the ternary form, distribution over unions, and infer — you can read and write the utility types that power most modern libraries. This post walks through each rule with examples you can keep.
The ternary at the type level
The syntax mirrors the value-level ternary:
// type-level
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
Read it as: “if T is assignable to string, the type is true; otherwise, false.” extends here is the assignability test, not class inheritance.
Combine with generics for something useful:
type NonNullable<T> = T extends null | undefined ? never : T;
type X = NonNullable<string | null>; // string
type Y = NonNullable<number | undefined>; // number
never is the “no values” type — when it appears in a union, it disappears. We will see why that matters in a moment.
Distribution over unions
Here is the rule that catches everyone the first time. When a conditional type’s checked type is a naked type parameter, the conditional distributes over each member of a union:
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;
// Equivalent to: ToArray<string> | ToArray<number>
// = string[] | number[]
// NOT (string | number)[]
The compiler “splits” string | number into its members, applies the condition to each, and unions the results. That is why NonNullable<string | null> works — string keeps itself, null becomes never, and string | never is just string.
Distribution is what makes conditional types feel magical. It is also the source of every weird bug.
Opting out of distribution
Sometimes you want the conditional to apply to the whole union, not to each member. Wrap both sides of extends in a tuple:
type IsUnion<T> = [T] extends [string | number] ? true : false;
type A = IsUnion<string | number>; // true
type B = IsUnion<string>; // true (still assignable)
A common pattern: detecting never. Naked T extends never distributes — and a union of zero things is just never, so never extends never returns never from the distribution machinery. The wrapped form avoids that:
type IsNever<T> = [T] extends [never] ? true : false;
type A = IsNever<never>; // true
type B = IsNever<string>; // false
The infer keyword
infer introduces a fresh type variable inside a conditional’s extends clause. The compiler matches the pattern and binds the variable to whatever fits.
type ElementOf<T> = T extends (infer U)[] ? U : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<number[]>; // number
type C = ElementOf<{ id: 1 }[]>; // { id: 1 }
type D = ElementOf<string>; // never (not an array)
The pattern says: “if T is an array of some element type, name that element type U and return it; otherwise never.”
infer only makes sense inside extends in a conditional. You cannot use it elsewhere.
Inferring from Promise
The same trick unwraps a Promise:
type Unpromise<T> = T extends Promise<infer U> ? U : T;
type A = Unpromise<Promise<string>>; // string
type B = Unpromise<Promise<number[]>>; // number[]
type C = Unpromise<string>; // string (passthrough)
This is roughly TypeScript’s built-in Awaited<T>, though Awaited also handles nested Promises and then-able objects:
type DeepUnpromise<T> =
T extends Promise<infer U> ? DeepUnpromise<U> : T;
type X = DeepUnpromise<Promise<Promise<string>>>; // string
Recursion in conditional types is allowed and routinely useful.
Inferring from function types
infer shines on function signatures. Each piece can be captured separately:
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type A = MyReturnType<() => string>; // string
type B = MyReturnType<(x: number) => boolean>; // boolean
You just wrote ReturnType from scratch. The standard library version is the same shape.
Inferring parameters as a tuple:
type MyParameters<F> = F extends (...args: infer P) => any ? P : never;
type A = MyParameters<(x: number, y: string) => void>;
// [x: number, y: string]
Tuple parameter lists carry the labels, which is why hovers show [x: number, y: string] rather than [number, string].
Try it yourself. Write FirstParameter<F> that extracts the first parameter of a function type, or never if the function takes no parameters. Then write LastParameter<F> for the last parameter. Test on (a: number, b: string, c: boolean) => void.
Inferring from a class constructor
Same pattern, different signature:
type InstanceTypeOf<C> = C extends new (...args: any[]) => infer I ? I : never;
class User { constructor(public name: string) {} }
type U = InstanceTypeOf<typeof User>; // User
This is the built-in InstanceType<T>. With infer, you can build every member of the standard utility set yourself.
A more practical example
Suppose you have a function that returns a discriminated union, and you want to extract one branch by its discriminant:
type Action =
| { type: 'add'; value: number }
| { type: 'remove'; id: string }
| { type: 'reset' };
type ActionOf<K extends Action['type']> =
Action extends infer A
? A extends { type: K } ? A : never
: never;
type AddAction = ActionOf<'add'>;
// { type: 'add'; value: number }
The double-conditional is a common idiom: the outer Action extends infer A distributes the union, and the inner A extends { type: K } filters each branch. This is roughly how the built-in Extract<T, U> works.
Building ReturnType — and going further
You have already seen MyReturnType. Here is a sequence of small types built from the same pieces:
// First parameter
type Head<F> = F extends (first: infer H, ...rest: any[]) => any ? H : never;
// All but the first
type Tail<F> = F extends (first: any, ...rest: infer R) => any ? R : never;
// Return value
type Out<F> = F extends (...args: any[]) => infer R ? R : never;
type F = (id: number, name: string, age: number) => boolean;
type A = Head<F>; // number
type B = Tail<F>; // [name: string, age: number]
type C = Out<F>; // boolean
Every advanced library — tRPC, Zod, Drizzle — relies on this style of inference to give you accurate types for code you never explicitly typed.
Conditional types meet keyof
Combined with keyof, conditional types let you pick keys by their value type:
type KeysMatching<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
interface User {
id: number;
name: string;
age: number;
isAdmin: boolean;
}
type NumberKeys = KeysMatching<User, number>; // 'id' | 'age'
type BoolKeys = KeysMatching<User, boolean>; // 'isAdmin'
The mapped type produces { id: 'id', name: never, age: 'age', isAdmin: never }, then [keyof T] indexes by every key, giving the union of values. never members vanish from the union. The next post — TypeScript Mapped Types — covers this machinery in full.
Try it yourself. Write OmitByType<T, V> that removes all properties of T whose value type is V. Use KeysMatching and the built-in Omit. Test it on the User interface above with V = boolean.
Pitfalls
A short field guide to the bugs you will hit:
- Accidental distribution.
T extends any ? T[] : neverdistributes; if you want(string | number)[], wrap with[T] extends [any]. inferin the wrong slot.infermust appear in theextendsclause of a conditional. It cannot live alone.- Variance surprises.
extendschecks assignability, which is not symmetric.string extends 'a'is false;'a' extends stringis true. - Distributive
never.never extends X ? Y : Zisnever, notYorZ. The conditional never evaluates because there is nothing to distribute over.
When not to reach for conditional types
Powerful does not mean obligatory. If a plain function signature or a simple generic conveys the contract, prefer that. Conditional types pay for themselves in libraries with many users; in application code, they often add cognitive cost for little gain. The test: can a colleague unfamiliar with the file read the type in under a minute? If not, simplify.
Recap
You now know:
- Conditional types use
T extends U ? X : Y - Naked type parameters distribute over unions
[T] extends [U]opts out of distributioninferintroduces a placeholder bound by pattern matching- You can infer from arrays, Promises, function parameters, returns, and constructors
- Built-in utilities like
ReturnType,Awaited, andExtractare just conditional types
Next steps
Conditional types compute a type. Mapped types compute every key of a type. Together they make TypeScript’s most expressive features feel like a small consistent language. The next post builds Partial, Readonly, and friends from scratch.
→ Next: TypeScript Mapped Types: Readonly, Partial from Scratch
Questions or feedback? Email codeloomdevv@gmail.com.