Lesson 15 / 23
Conditional Types
Type-level branching, the distribution rule over unions and closing off distribution, extracting a type piece with infer, and the limit of conditional return types.
Contents
The previous lesson used a notation of the form T[A] extends number ? A : never and
branched at the type level. A conditional type is the structure at the center of
the type system’s computing ability: it picks one of two types depending on whether one
type is assignable to another.
Its notation is the type-level counterpart of the ternary operator:
This lesson covers how the condition is evaluated, its special behavior over unions,
extracting a piece with infer, and the limits of the structure.
Branching at the Type Level
type IsNumber<T> = T extends number ? "number" : "not a number"; const a: IsNumber<number> = "number"; const b: IsNumber<string> = "not a number"; console.log(a, b); const c: IsNumber<number> = "not a number";
p1.ts(7,7): error TS2322: Type '"not a number"' is not assignable to type '"number"'.
IsNumber<number> resolves to "number", IsNumber<string> to "not a number". The
seventh line’s diagnostic shows the resolution really happened: the compiler reports
the error by writing "number" in place of IsNumber<number>.
The word extends here does not mean inheritance but an assignability test: “can
T be put where U is expected?” The rules established in the Structural Type
Compatibility lesson apply here.
Distribution
If a conditional type’s type parameter is a union, the condition is evaluated separately for each member and the results are combined into a union. This is called distribution:
type IsNumber<T> = T extends number ? "number" : "not a number"; type NonDistributive<T> = [T] extends [number] ? "number" : "not a number"; declare const distributed: IsNumber<number | string>; declare const sealed: NonDistributive<number | string>; const x: null = distributed; const y: null = sealed;
p2.ts(7,7): error TS2322: Type '"not a number" | "number"' is not assignable to type 'null'. Type '"not a number"' is not assignable to type 'null'. p2.ts(8,7): error TS2322: Type '"not a number"' is not assignable to type 'null'.
The diagnostics spell out both types explicitly. IsNumber<number | string> has
become "not a number" | "number"; the condition was evaluated first for number,
then for string. NonDistributive<number | string> gave a single result:
"not a number".
What closes off distribution is wrapping the type parameter in a tuple. In the
notation [T] extends [U], what is being tested is no longer T but a single-element
tuple; it does not split into union members, and the condition is evaluated once.
Which of the two behaviors is wanted depends on intent. If every member of a union
needs to be transformed, distribution is needed. If the question being asked is “does
all of this type satisfy this condition?”, it should be closed off — otherwise a
meaningless result like “partly a number” comes out for the number | string union.
Distribution only applies to bare type parameters: the left side of the condition
must be directly T. There is no distribution in notations like T[], [T], or
{ value: T }.
Filtering: Drop
Distribution’s most common use is eliminating a member from a union. never is
produced for the member to be filtered; never takes up no space in a union:
type Drop<T, U> = T extends U ? never : T; type Unit = "C" | "Pa" | "%"; type MeasurableUnit = Drop<Unit, "%">; const units: MeasurableUnit[] = ["C", "Pa"]; console.log(units.join(","));
The output is C,Pa. If the line const wrong: MeasurableUnit = "%"; is added at the
end of the file, after a blank line:
p5.ts(9,7): error TS2322: Type '"%"' is not assignable to type 'MeasurableUnit'.
The steps of the computation are as follows: the condition is evaluated separately for
the three members; "C" and "Pa" are not assignable to "%" so they produce
themselves, and "%" produces never. The result is "C" | "Pa" | never, which is
"C" | "Pa".
This transform’s standard-library counterpart is Exclude, covered in the Utility
Types lesson.
Extracting a Piece with infer
A type variable can be declared on the left side of the condition with the infer
keyword. During matching, the compiler finds the type corresponding to that variable:
interface Measurement { id: string; value: number; } type ElementType<T> = T extends readonly (infer E)[] ? E : never; declare const element: ElementType<Measurement[]>; declare const stringElement: ElementType<readonly string[]>; declare const nonArray: ElementType<number>; const a: null = element; const b: null = stringElement; const c: null = nonArray;
p3.ts(12,7): error TS2322: Type 'Measurement' is not assignable to type 'null'. p3.ts(13,7): error TS2322: Type 'string' is not assignable to type 'null'.
ElementType<Measurement[]> has become Measurement, ElementType<readonly string[]> has become string. The fourteenth line producing no diagnostic also
carries information: ElementType<number> is never, and never is assignable to
every type — including null.
The same technique is used to extract a payload type from a course-wide result type:
interface Measurement { id: string; value: number; } type Result<D, E = string> = | { status: "success"; value: D } | { status: "error"; error: E }; type SuccessType<S> = S extends { status: "success"; value: infer D } ? D : never; type ErrorType<S> = S extends { status: "error"; error: infer E } ? E : never; declare const success: SuccessType<Result<Measurement>>; declare const error: ErrorType<Result<Measurement, number>>; const a: null = success; const b: null = error;
p4.ts(16,7): error TS2322: Type 'Measurement' is not assignable to type 'null'. p4.ts(17,7): error TS2322: Type 'number' is not assignable to type 'null'.
Distribution is at work here too: Result<Measurement> is a two-member union;
computing SuccessType gives Measurement for the success member, never for the
error member, and the union reduces to Measurement.
The value of this pattern is being able to reach into a piece of a type without
repeating its definition. If Result’s structure changes, SuccessType changes with
it; no manually maintained link remains between the two.
The Limit of Conditional Return Types
Conditional types can be used in function signatures, but they do not resolve inside the body:
type Formatted<T> = T extends number ? string : number; function format<T extends number | boolean>(value: T): Formatted<T> { if (typeof value === "number") { return value.toFixed(1); } return 0; } console.log(format(21.4), format(true));
p7.ts(5,5): error TS2322: Type 'string' is not assignable to type 'Formatted<T>'. p7.ts(7,3): error TS2322: Type 'number' is not assignable to type 'Formatted<T>'.
Inside the body, Formatted<T> cannot be resolved because T is not yet known; the
compiler cannot infer that the typeof value test also narrows T. The diagnostic
comes on both branches.
This is a known limit of the type system. There are two fixes: using a single type assertion in the body — a debt that makes the limit visible and accepts that the body honors the promise made in the signature — or writing an overload instead of a conditional type. The second is the structure built in the Function Types lesson, and gives callers the same result.
General principle: conditional types are powerful for computing a type, not for driving an implementation.
Summary
- A conditional type picks one of two types based on an assignability test;
extendshere means assignability, not inheritance. - If a bare type parameter is a union, the condition is evaluated separately for each
member and the results are combined into a union; the notation
[T] extends [U]closes off this distribution. - Distribution together with
neverbuilds filters that eliminate a member from a union. inferdeclares a type variable on the left side of the condition and extracts the type corresponding to it during matching; it lets a piece of a type be reached without repeating its definition.- Conditional return types do not resolve inside the function body; this case needs either a type assertion or an overload.
Next Step
The key remapping lesson used a notation of the form `read${Capitalize<string & A>}` and computed a field name. Combining and parsing strings at the type level is
a separate toolset. The next lesson covers template literal types and how they are
used together with infer to parse string patterns.
To keep your progress and take notes, Log in
My notes
Log in to take notes.