Conditional Types
Types that branch on other types: the extends check, distribution over unions and how to stop it, and the recursion that makes them genuinely powerful.
Types that branch on other types: the extends check, distribution over unions and how to stop it, and the recursion that makes them genuinely powerful.
A function returns a single item when given an id and an array when given a list of ids. You write two overloads, then a third case appears, then a fourth, and the signature is longer than the implementation.
What you want is a type that computes: given this input type,
produce that output type. By the end of this lesson you will
write conditional types, know the distribution behaviour that
makes them powerful and occasionally surprising, and be able to
extract types out of other types with infer.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // falseRead T extends string as "is T assignable to string?" —
the same relationship as an assignment, asked as a question.
The useful version takes an argument and shapes the result:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<Photo>>; // Photo
type B = Unwrap<Photo>; // Photoinfer U declares a type variable inside the check.
"If T is a Promise of something, call that something U,
and give me U." It is pattern matching for types, and it is
what makes conditional types more than a switch.
Applied to the opening problem:
type Result<T> = T extends string[] ? Photo[] : Photo;
function load<T extends string | string[]>(ids: T): Result<T> { ... }
load("abc"); // Photo
load(["a", "b"]); // Photo[]One signature, computing its return type from its argument.
Now the behaviour that surprises everyone once.
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string>; // string[]
type B = ToArray<string | number>; // string[] | number[]You might expect (string | number)[]. Here is what happened
instead:
string | number
One union goes in.
string, then number
Checked separately, as if you had written the type twice.
string[] | number[]
Two results, recombined into a union.
That is distribution, and it happens whenever the checked type is a bare type parameter. It is the mechanism behind the built-in filters:
type Exclude<T, U> = T extends U ? never : T;
type A = Exclude<"a" | "b" | "c", "b">; // "a" | "c"Each member is tested; "b" becomes never; and never
disappears from a union — the fact from the unknown, any, never lesson, now doing real work. Filtering a union is
distribution plus that disappearance.
To stop distributing, wrap both sides in brackets:
type ToArray<T> = [T] extends [unknown] ? T[] : never;
type B = ToArray<string | number>; // (string | number)[][T] is no longer a bare parameter, so the whole union is
checked at once. This is the standard trick and it looks like
nonsense until you know why.
The other case it fixes:
type IsNever<T> = T extends never ? true : false;
type A = IsNever<never>; // never - not true!
type IsNever2<T> = [T] extends [never] ? true : false;
type B = IsNever2<never>; // trueDistributing over never — a union with no members — produces
no results at all. Bracketing fixes it.
infer can appear anywhere in the checked pattern:
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type ReturnOf<T> = T extends (...args: never[]) => infer R ? R : never;
type FirstArg<T> = T extends (first: infer A, ...rest: never[]) => unknown
? A
: never;type A = ElementOf<Photo[]>; // Photo
type B = ReturnOf<() => Promise<Photo>>; // Promise<Photo>Several infers in one pattern work, and constraints on them
narrow what matches:
type FirstString<T> = T extends [infer A extends string, ...unknown[]]
? A
: never;Recursion is where this becomes genuinely powerful:
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type A = DeepAwaited<Promise<Promise<Photo>>>; // PhotoThe built-in Awaited is exactly this. Recursion has a depth
limit — around fifty by default — and hitting it produces an
"excessively deep" error, which is the compiler protecting you
from a type that would never finish.
Bad — a conditional type doing the work of a union.
type Response<T> = T extends "photo"
? { photo: Photo }
: T extends "album"
? { album: Album }
: T extends "user"
? { user: User }
: never;
function fetch<T extends string>(kind: T): Response<T> { ... }Good — a lookup, or a discriminated union.
type ResponseMap = {
photo: { photo: Photo };
album: { album: Album };
user: { user: User };
};
function fetch<K extends keyof ResponseMap>(kind: K): ResponseMap[K] { ... }Evaluated in the reader's head.
Every error message mentioning it prints the whole expression rather than the answer.
Adding a kind means another layer of nesting, and nothing
checks the chain is exhaustive — a typo gives never,
silently.
A table anyone can read.
keyof keeps the argument honest, the error names the key,
and adding an entry is one line in an obvious place.
When each input maps to a fixed output, a lookup beats a conditional. Reserve conditionals for genuine computation — unwrapping, filtering, transforming a shape.
Three that come up in real code.
Filtering keys by their value type:
type KeysOfType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
type StringKeys = KeysOfType<Photo, string>; // "id" | "name"That combines a mapped type with a conditional and then indexes the result to collapse it into a union. The mapped-types lesson covers the first half.
Making some fields optional:
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type Draft = PartialBy<Photo, "id" | "createdAt">;Requiring at least one of a set:
type AtLeastOne<T, K extends keyof T = keyof T> = K extends unknown
? Required<Pick<T, K>> & Partial<Omit<T, K>>
: never;That one distributes deliberately: one union member per possible required key.
THE FORM
type X<T> = T extends Pattern ? Then : Else;
`extends` asks "is T assignable to this?"
infer - pattern matching
T extends Promise<infer U> ? U : T
T extends readonly (infer E)[] ? E : never
T extends (...a: never[]) => infer R ? R : never
infer A extends string constrain what matches
recursion works; ~50 deep, then "excessively deep"
DISTRIBUTION
a BARE type parameter distributes over a union
ToArray<string | number> -> string[] | number[]
NOT (string | number)[]
that is how Exclude works: each member tested, never removed,
and never disappears from a union
to STOP it: [T] extends [U] ? ... : ...
also fixes IsNever - distributing over never gives nothing
WHEN NOT TO
a chain of nested conditionals mapping inputs to outputs
-> a lookup type + keyof, which is readable and exhaustive
reserve conditionals for real computation: unwrap, filter,
transform
USEFUL SHAPES
KeysOfType<T, V> { [K in keyof T]: T[K] extends V ? K : never }[keyof T]
PartialBy<T, K> Omit<T, K> & Partial<Pick<T, K>>
AtLeastOne<T> distributes on purpose, one member per key
READING ONE
substitute a concrete type and evaluate by hand
hovering shows the result, not the branch taken
leave a worked example in a commentYou can now write types that compute rather than describe,
extract types out of other types with infer, and control
distribution — including the bracket trick that looks arbitrary
until you know it is about bare type parameters.
Next is Mapped Types and Key Remapping, the other half of this machinery. Conditionals branch; mapped types transform every property of a type at once, and the two combine into most of the utility types you have been using.
Before you move on, write Unwrap<T> and apply it to
Promise<Photo>, to Photo, and to Promise<Photo> | string.
The third answer requires you to work out distribution, which is
the concept from this lesson most worth having in your hands
rather than your notes.