2019-07-15~5 min

Mapped Types in TypeScript

Mapped types, introduced in TypeScript 2.1, can significantly reduce typing effort. They can be hard to understand though, as they unfold their full potential only in combination with other (complicated) features.

keyof and Indexed Types

Lets start with the features necessary for mapped types, before taking a full dive.

keyof, also called the index type query operator, creates a literal string union of the public property names of a given type.

interface I {
  a: string;
  b: number;
}

type Properties = keyof I;
// Properties = "a" | "b"

Indexed types, specifically the indexed access operator allow accessing the type of a property and assigning it to a different type. With the same interface I from above, it's possible to get the type of property a simply by accessing it.

type PropertyA = I['a'];
// PropertyA = string

It's also possible to pass multiple properties as a union, which yields a union of the respective property types.

type PropertyTypes = I['a' | 'b'];
// PropertyTypes = string | number

Both features also work in combination.

type PropertyTypes = I[keyof I];
// PropertyTypes = string | number

The indexed access operator is also type-checked, so accessing a property that doesn't exist would lead to an error.

type PropertyA = I['nonexistent'];
// Property 'nonexistent' does not exist on type 'I'.

Simple Mapped Types

With the basics down we can move on to mapped types themselves. In general, a mapped type maps a list of strings to properties. The list of strings is defined as a literal string union.

type Properties = 'a' | 'b' | 'c';

A simple mapped type based on that could look like this

type T = { [P in Properties]: boolean };
// type T = {
//   a: boolean;
//   b: boolean;
//   c: boolean;
// }

All it does is iterate over each possible string value and create a boolean property out of it.

By itself this is not terribly useful, but adding generics to the mix will be a great improvement. With it, it's possible to define a mapped type that makes every property optional.

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

type IPartial = Partial<I>; // 'I' is the interface defined on top
// type IPartial {
//   a?: string;
//   b?: string;
// }

It looks a bit more complicated, but uses the same structure as the simpler definition before. The major difference here is that it takes an existing type and adapts the properties.

First it uses keyof to get a literal string union of all property names (keyof T). Then iterates over all of them ([P in keyof T]) and makes them optional by adding the question mark. The indexed access operator (T[P]) assigns the same type the property has on the given type, to the newly created one.

It's not limited to make properties optional. Every modifier and type can be used. It's not even necessary to use the original property type. For example changing every property into a number

type ToNumber<T> = { [P in keyof T]: number };

or into a Promise

type ToPromise<T> = { [P in keyof T]: Promise<T[P]> };

It's even possible to remove modifiers, by adding a - in front of it. For example removing the readonly modifier from all properties of a type.

type RemoveReadonly<T> = { -readonly [P in keyof T]: T[P] };

The same thing works for removing the optional marker, effectively marking the property required:

type RemoveOptional<T> = { [P in keyof T]-?: T[P] };

One thing to note here is that mapped types don't apply to basic types.

type MappedBasic = Partial<string>;
// type MappedBasic = string

This covers the basics of mapped types.
The next sections will show how mixing together additional advanced TypeScript features makes them even more powerful (and complicated).

Conditional Types

TypeScript 2.8 introduced conditional types, which select a possible type based on a type relationship test. For example

T extends Function ? string : boolean

It can be used wherever generics are available, such as the return type of a function.

declare function f<T>(p: T): T extends Function ? string : boolean;

If the parameter p is a function, the return type is string, if not it's boolean.

The same is true for classes

class C<T> {
  value: T extends Function ? string : boolean;
}

and type aliases

type T1<T> = T extends Function ? string : boolean;
type T2 = T1<() => number>;
// T2 = string

Distributive Conditional Types

Conditional types have a special case, namely if the type parameter to a conditional type is a union. It's called a distributive conditional type. In that case, the conditional type is applied separately to each type making up the union.

To illustrate:

type T1<T> = T extends string ? string : boolean;
type Union = 'a' | 'b' | true;
type T2 = T1<Union>;
// T2 = string | boolean

What happens here is that T1 is applied separately to 'a', 'b' and true and the results combined back to a union, which yields string | string | boolean. The two strings can be combined so the end result is string | boolean.

While the TypeScript team has given this case for conditional types a special name, it also applies for mapped types. Similar to conditional types, applying a mapped type on a union will apply it separately on each type making up the union and combine it back together.

interface I1 {
  p1: boolean;
}
interface I2 {
  p2: string;
}
interface I3 {
  p3: number;
}
type Union = I1 | I2 | I3;
type T = Partial<Union>;
// T = Partial<I1> | Partial<I2> | Partial<I3>

Enhanced Mapped Types

Up until now, every type mapping was uniform, in the sense that either all properties had the same type (e.g. string), or each of them had the corresponding type from the original type. The only exception being modifiers.

Conditional types add the ability to express non-uniform type mappings. For example keeping all function property types while changing all other properties to boolean.

interface I {
  p1: () => void;
  p2: (a: string) => boolean;
  p3: string;
  p4: string;
}

type T1<T> = 
  { [P in keyof T]: T[P] extends Function ? T[P] : boolean };

type T2 = T1<I>;
T2 = {
  p1: () => void;
  p2: (a: string) => boolean;
  p3: boolean;
  p4: boolean;
}

Final Words

What I found was that in normal application code, mapped types are rarely needed. They are much more useful for library and framework code. Most people won't have to write them themselves, but will encounter them when reading type definitions of libraries.

I hope that you have a better understanding about mapped types and related TypeScript features now, so that you have at least an easier time understanding the ones of the packages you depend on.