← Home

Notes on Conditional Types in TypeScript

type Result = X extends B ? C : D;

In TypeScript, X extends B asks if X is a superset of B — if X contains all of the properties of B and potentially more. That is, X extends B asks if a type is a more specific version of another type: Circle extends Shape, Dog extends Animal, etc.

By implication, the check in a conditional type asks if X is assignable to B — if any value of type X can be safely assigned to a variable of type B. A more specific version of a base type can always be assigned to that base type, so if X happens to be a more specific version of general type B, then X is always a member of B, and hence X is assignable to B.

X and B
The base type B contains a certain number of properties; the more specific type X is a superset of B because X contains all of B's properties and more. The size of the set (type) corresponds to the number of elements (properties) in the type.
type X = {
  name: string;
  age: number;
  isMarried: boolean;

type R1 = X extends { name: string } ? true : false; // true
type R2 = X extends { age: number } ? true : false; // true
type R3 = X extends { name: string; age: number } ? true : false; // true
type R4 = X extends { address: object } ? true : false; // false

Top and bottom types

A top type is a type to which all other types are assignable, i.e. a top type is the universal set, the set that encompasses all others — all other types are more specific versions of the top type. In TypeScript, the two top types are:

  • any → No information is available about this type. Usage is assumed correct, so it is not required to check its properties before usage, making issues possible at runtime.
  • unknown → No information is available about this type. Usage is assumed incorrect, so it is required to check its properties before usage, preventing issues at runtime.

We can use any and unknown to set up always-true conditions: X extends any and X extends unknown. These are known as ad hoc conditional types, whose purpose is to enable the use of the special features of conditional types, rather than to express a normal condition. (Relatedly, we can also use X extends X, an always-true condition, to create an ad hoc conditional type without relying on top types.)

The special properties of conditional types

For more on the infer keyword and distributive conditional types, see below.

In turn, a bottom type is a type to which no other type is assignable, ever. A bottom type cannot exist. In TypeScript, the only bottom type is never.

We can use never to refine unions. If we set the false result of the check to never when the checked type is a union, as in X extends B ? C : never with X being a union, then the distributive property of conditional types creates a union of all results, either C or never, and since never in union with other types is always those other types, the types that fail to meet the condition are filtered out from the resulting union.

Purposes of conditional types

Relating types

In a function, we can use a conditional type to express the relationship between input and output types — if the argument to a function is of one type (input type 1), then the return value should be of a type (output type 1), but if the argument to the function is of another type (input type 2), then the return value should be of another type (output type 2).

In the following example, if the argument to a function is of type string, then the output type will be of type { name: string }, but if the argument to a function is of type number, then the output type will be of type { id: number }.

type Output<T extends number | string> = T extends number
  ? { id: number }
  : { name: string };

function process<T extends number | string>(arg: T): Output<T> {
  // ...

const r1 = process(123); // `r1` is of type `{ id: number }`
const r2 = process("john"); // `r2` is of type `{ name: string }`

Destructuring types

A conditional type can extract and name a piece of a type, so that this piece can be reused. This is possible with the infer keyword, which declares a type variable for the piece of a type. The infer keyword can only appear to the right of the extends keyword in a conditional type.

type UnwrapArrayType<T> = T extends (infer R)[] ? R : never;

type R1 = UnwrapArrayType<string[]>; // `R1` is `string`
type R2 = UnwrapArrayType<number[]>; // `R2` is `number`

type Flip<T> = T extends [infer A, infer B] ? [B, A] : never;
type R3 = Flip<["first", "second"]>; // `R3` is `["second", "first"]`

type ReturnTypeOf<T> = T extends (...args: never[]) => infer R ? R : never;
type R4 = ReturnTypeOf<() => number>; // `R4` is `number`

Transforming types

A conditional type distributes a transformation over a union. To transform each member in a union, we can create an ad hoc conditional type and operate on the output of the true branch. The effect is that the transformation is applied to each member in the union, and the resulting type is the union of all the transformed members.

type ToArray<T> = T extends any ? T[] : never;
type R1 = ToArray<string | number>; // `R1` is `string[] | number[]`

In the example above, T extends any is an “artificial” condition, one that is always true and where never is never reached, a condition only meant to trigger distributivity.

Not all conditional types trigger are distributive. A conditional type distributes only when:

  1. the condition checks a generic, and
  2. the checked generic is “naked” (alone to the left of the extends keyword), and
  3. a union is passed in to the generic.

That is, for a conditional type to distribute over a union, the union needs to be bound to a type variable by means of a generic that is unprocessed.

// distributive conditional type
type Result<T> = T extends SomeType ? A : B;

// non-distributive, checked type is generic but is not naked
type Result<T> = SomeOtherType<T> extends SomeType ? A : B;

// non-distributive, checked type is defined elsewhere, not generic
type Result<T> = SomeType extends T ? A : B;

Distributivity can be used to transform a union of objects into a union of keys, i.e. to transform each member of the union into its keys, with all the results unionized.

type Professional =
  | { name: string; field: string }
  | { name: string; speciality: string };

type KeyOfAll<T> = T extends T ? keyof T : never;

type R = KeyOfAll<Professional>; // `R` is `"name" | "field" | "speciality"`

Distributivity also enables various transformations, e.g. mapping from an object type to a union of objects containing a single key-value pair.

type ObjectToUnionOfObjectsOfKeyValuePairs<T extends object> = {
  [K in keyof T]: { key: K; value: T[K] };
}[keyof T];

Each key of the original object becomes a key of another object and is used to index into that other object. The output of the indexing operation is a union of each value of the larger object. Each of those values is where the transformation occurs.

type R1 = ObjectToUnionOfObjectsOfKeyValuePairs<{
  name: string;
  age: number;
  isMarried: boolean;
// `R1` is
//      | { key: "name"; value: string }
//      | { key: "age"; value: number }
//      | { key: "isMarried"; value: boolean }

The opposite operation is a mapping without using a distributive conditional type:

type KeyValuePair = { key: PropertyKey; value: unknown };

type UnionOfKeyValuePairsToObject<T extends KeyValuePair> = {
  [K in T["key"]]: Extract<T, { key: K }>["value"];

type R2 = UnionOfKeyValuePairsToObject<R1>;
// `R2` is `{ name: string; age: number; isMarried: boolean }`

PropertyKey is a built-in alias for string | number | symbol, i.e. the types usable as object keys.

  1. T is the union of KeyValuePairs, so T["key"] is the union of all the values pointed at by the "key" keys in each KeyValuePair, i.e. "age" | "number" | "isMarried". These keys are looped over during the mapping.
  2. Extract<T, { key: K }> extracts, from the union of KeyValuePairs, the specific member where the key is the current key in the loop.
  3. ["value"] indexes into the specific member that was extracted, returning the value pointed at by the key "value".

The end result is that every key is paired with its value, building the original object back up.

To disable distributivity in a conditional type, wrap each part of the condition with square brackets.

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type R = ToArrayNonDist<string | number>; // `R` is `(string | number)[]`