codejump

AcademyTypeScript

Mapped and conditional types

How to write the utility types you have been importing: looping over keys, branching on a type, and pulling a type back out with infer.

4 min readUpdated

3 exercises

Waiting at the end of this page.

A loop, at the type level

A mapped type walks the keys of a type and produces a new one. That is all Partial is:

TypeScript
type Partial<T> = {
  [K in keyof T]?: T[K];
};

Read it as a for...in: for each key K in keyof T, make a property of type T[K], optional. Every one of the built-in utilities is this shape, which is why they stop being magic once you have written one.

TypeScript
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

type Stringify<T> = {
  [K in keyof T]: string;
};

Modifiers can be added or removed, with + and -:

TypeScript
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type Concrete<T> = {
  [K in keyof T]-?: T[K];   // this is Required<T>
};

Renaming keys as you go

as inside a mapped type rewrites the key. With template literal types, that is how a getter interface gets generated:

TypeScript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

Map a key to never and it disappears — which is how you filter properties by their type:

TypeScript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type S = OnlyStrings<{ id: string; age: number }>;  // { id: string }

A conditional type is a type-level ternary

TypeScript
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

extends here means "is assignable to", not inheritance. The useful ones combine it with a generic:

TypeScript
type ElementType<T> = T extends (infer U)[] ? U : T;

type A = ElementType<string[]>;  // string
type B = ElementType<number>;    // number

infer names a type the compiler works out

infer U says: match this position, and call whatever is there U. It only appears inside the extends clause of a conditional type, and it is how every "unwrap" utility works:

TypeScript
type Awaited<T> = T extends Promise<infer U> ? U : T;
type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type Parameters<F> = F extends (...args: infer P) => any ? P : never;

type A = Awaited<Promise<string>>;             // string
type B = ReturnType<() => { id: string }>;     // { id: string }

Once you can read those three, most of lib.es5.d.ts is readable.

The distribution rule, and how to switch it off

A conditional type over a naked type parameter distributes across a union — it runs once per member:

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

type A = ToArray<string | number>;  // string[] | number[]
                                    // not (string | number)[]

This is usually what you want — it is how Exclude works — and occasionally exactly what you do not. Wrapping both sides in a tuple stops it:

TypeScript
type IsUnion<T> = [T] extends [string] ? true : false;

type A = IsUnion<string | number>;  // false, checked as a whole
WantWrite
Loop over keys{ [K in keyof T]: ... }
Rename keys[K in keyof T as NewKey]
Drop a keymap it to never
Branch on a typeT extends X ? A : B
Capture part of a typeinfer U inside the extends
Stop distributing over a union[T] extends [X]

When to stop

Type-level programming is genuinely fun, and that is its danger. A type nobody on the team can read is worse than a small amount of duplication: the error messages it produces are unreadable too, and they land on whoever is unlucky enough to pass the wrong argument. Write the clever type when it removes a real class of bug — and when it does, write a test for it, because a type has behaviour like anything else.

Now practice it

2 challenges · 1 quiz

Reading this page is the cheap half. These are the exercises that make you use it.