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:
We need two interfaces. In addition to the
Point
type, we had to define theFrozenPoint
type so that we could add thereadonly
modifier to the two properties. When we changePoint
, we also have to changeFrozenPoint
, which is both error-prone and annoying.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 typeObject.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 namedT
. - Within the square brackets, we're using the
keyof
operator.keyof T
represents all property names of typeT
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 propertyP
of typeT
should be transformed toT[P]
. Without thereadonly
modifier, this would be an identity transformation. - The type
T[P]
is a lookup type. It represents the type of the propertyP
of the typeT
. - 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 thesetState
method a great use case forPartial<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 usingPick<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!