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 string
s 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.