Marius Schulz
Marius Schulz
Front End Engineer

Mapped Types in TypeScript

TypeScript 2.1 introduced mapped types, a powerful addition to the type system. In essence, mapped types allow you to create new types from existing ones by mapping over property types. Each property of the existing type is transformed according to a rule that you specify. The transformed properties then make up the new type.

Using mapped types, you can capture the effects of methods such as Object.freeze() in the type system. After an object has been frozen, it's no longer possible to add, change, or remove properties from it. Let's see how we would encode that in the type system without using mapped types:

interface Point {
  x: number;
  y: number;
}

interface FrozenPoint {
  readonly x: number;
  readonly y: number;
}

function freezePoint(p: Point): FrozenPoint {
  return Object.freeze(p);
}

const origin = freezePoint({ x: 0, y: 0 });

// Error! Cannot assign to 'x' because it
// is a constant or a read-only property.
origin.x = 42;

We're defining a Point interface that contains the two properties x and y. We're also defining another interface, FrozenPoint, which is identical to Point, except that all its properties have been turned into read-only properties using the readonly keyword.

The freezePoint function takes a Point as a parameter, freezes it, and returns the same object to the caller. However, the type of that object has changed to FrozenPoint, so its properties are statically typed as read-only. This is why TypeScript errors when attempting to assign 42 to the x property. At run-time, the assignment would either throw a TypeError (in strict mode) or silently fail (outside of strict mode).

While the above example compiles and works correctly, it has two big disadvantages:

  1. We need two interfaces. In addition to the Point type, we had to define the FrozenPoint type so that we could add the readonly modifier to the two properties. When we change Point, we also have to change FrozenPoint, which is both error-prone and annoying.

  2. We need the freezePoint function. For each type of object that we want to freeze in our application, we have to define a wrapper function that accepts an object of that type and returns an object of the frozen type. Without mapped types, we can't statically type Object.freeze() in a generic fashion.

Thanks to TypeScript 2.1, we can do better.

#Modeling Object.freeze() with Mapped Types

Let's now see how Object.freeze() is typed within the lib.d.ts file that ships with TypeScript:

/**
  * Prevents the modification of existing property attributes and values, and prevents the addition of new properties.
  * @param o Object on which to lock the attributes.
  */
freeze<T>(o: T): Readonly<T>;

The method has a return type of Readonly<T> — and that's a mapped type! It's defined as follows:

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

This syntax may look daunting at first, so let's disassemble it piece by piece:

  • We're defining a generic Readonly type with a single type parameter named T.
  • Within the square brackets, we're using the keyof operator. keyof T represents all property names of type T as a union of string literal types.
  • The in keyword within the square brackets signals that we're dealing with a mapped type. [P in keyof T]: T[P] denotes that the type of each property P of type T should be transformed to T[P]. Without the readonly modifier, this would be an identity transformation.
  • The type T[P] is a lookup type. It represents the type of the property P of the type T.
  • Finally, the readonly modifier specifies that each property should be transformed to a read-only property.

Because the type Readonly<T> is generic, Object.freeze() is typed correctly for every type we provide for T. We can now simplify our code from before:

const origin = Object.freeze({ x: 0, y: 0 });

// Error! Cannot assign to 'x' because it
// is a constant or a read-only property.
origin.x = 42;

Much better!

#An Intuitive Explanation of the Syntax for Mapped Types

Here's another attempt to explain roughly how the type mapping works, this time using our concrete Point type as an example. Note that the following is only an intuitive approach for explanatory purposes that doesn't accurately reflect the resolution algorithm used by TypeScript.

Let's start with a type alias:

type ReadonlyPoint = Readonly<Point>;

We can now substitute the type Point for each occurrence of the generic type T in Readonly<T>:

type ReadonlyPoint = {
  readonly [P in keyof Point]: Point[P];
};

Now that we know that T is Point, we can determine the union of string literal types that keyof Point represents:

type ReadonlyPoint = {
  readonly [P in "x" | "y"]: Point[P];
};

The type P represents each of the properties x and y. Let's write those as separate properties and get rid of the mapped type syntax:

type ReadonlyPoint = {
  readonly x: Point["x"];
  readonly y: Point["y"];
};

Finally, we can resolve the two lookup types and replace them by the concrete types of x and y, which is number in both cases:

type ReadonlyPoint = {
  readonly x: number;
  readonly y: number;
};

And there you go! The resulting ReadonlyPoint type is identical to the FrozenPoint type that we created manually.

#More Examples for Mapped Types

We've seen the Readonly<T> type that is built into the lib.d.ts file. In addition, TypeScript defines additional mapped types that can be useful in various situations. Some examples:

/**
 * Make all properties in T optional
 */
type Partial<T> = {
  [P in keyof T]?: T[P];
};

/**
 * From T pick a set of properties K
 */
type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

/**
 * Construct a type with a set of properties K of type T
 */
type Record<K extends string, T> = {
  [P in K]: T;
};

And here are two more examples for mapped types that you could write yourself if you have the need for them:

/**
 * Make all properties in T nullable
 */
type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

/**
 * Turn all properties of T into strings
 */
type Stringify<T> = {
  [P in keyof T]: string;
};

You can have fun with mapped types and combine their effects:

type X = Readonly<Nullable<Stringify<Point>>>;
// type X = {
//     readonly x: string | null;
//     readonly y: string | null;
// };

Good stuff!

#Practical Use Cases for Mapped Types

I want to finish this post by motivating how mapped types could be used in practice to more accurately type frameworks and libraries. More specifically, I want to look at React and Lodash:

  • React: A component's setState method allows you to update either the entire state or only a subset of it. You can update as many properties as you like, which makes the setState method a great use case for Partial<T>.
  • Lodash: The pick utility function allows you to pick a set of properties from an object. It returns a new object containing only the properties you picked. That behavior can be modeled using Pick<T>, as the name already suggests.

Note that at the time of writing, none of the above changes have been made to the corresponding type declaration files on DefinitelyTyped.

This article and 44 others are part of the TypeScript Evolution series. Have a look!