TypeScript Thursday - S01E02 - Mapped types
Welcome to TypeScript Thursday, a series of articles covering TypeScript's type system. Today's episode is about mapped types.
Introduction
The last episode TypeScript Thursday - S01E01 introduced generic types, how they work, and how we can use type inference for function calls. All the examples shown there took one or several generic type parameters and produced types that contained them somewhere e.g. Box<T> = { content: T }
.
In this episode, we're going to look over some techniques for transforming the generic type parameters and producing more complex types. A very common mapped type that you might have already used is the built-in Record
type that lets us define objects with arbitrary keys and known value types:
const numberMap: Record<string, number> = {
arbitrary: 1,
keys: 2,
};
You may have also used Pick
which lets us "pick" keys from an existing type:
type Person = {
firstName: string;
lastName: string;
age: number;
}
type Name = Pick<Person, 'firstName' | 'lastName'>;
// ^? { firstName: string; lastName: string }
The article will cover those examples and much more advanced ones. But first, we have to go over some basics that we'll use later to build our mapped types. Read on!
keyof
keyof
will come in handy later when we'll build mapped types that copy the keys from a different type.
The keyof
type operator takes a type and returns a union of its keys:
type Point = { x: number; y: number };
type Keys = keyof Point;
// ^? 'x' | 'y'
It can also work in combination with generic types:
type Keys<T> = keyof T;
type Foo = Keys<{ foo: string; bar: number };
// ^? 'foo' | 'bar'
Using the operator on a union of types will give us only the keys that are common between the members.
type Foo = { foo: string; bar: number }
type Bar = { bar: number; baz: () => void }
type Keys = keyof (Foo | Bar)
// ^? 'bar'
This might seem counterintuitive, but consider the following:
const get = (foo: Foo, bar: Bar, key: Keys) => {
return foo[key] * bar[key];
}
If keyof
would return the union of keys of both types, we might try to access a key that exists on Bar
, but not on Foo
.
What happens if we use the operator on a type that isn't an object?
type K1 = keyof number;
// ^? 'toString' | 'toFixed' | 'toExponential' ...
keyof
on a number
gives us all the properties available on numbers, such as toString
, toFixed
, toExponential
etc.
type K2 = keyof number[];
// ^? 0, 1, 2, 3, 4, ..., push, pop,...
type K3 = keyof [number, boolean];
// ^? 0, 1, push, pop, ...
keyof
on any kind of an array will give us the numeric keys and the array-specific properties like push
and pop
. Using it on tuples will work similarly to arrays, but the numeric keys will be limited to the ones defined in the tuple type. Above we only have 2 members in the tuple, so the returned keys are 0
and 1
. For arrays we get an infinite number of positive numbers.
We can use keyof
in combination with generic types to create a type-safe sort
function that sorts an array of objects by a specific key:
const sort = <T, K extends keyof T>(
objects: T[],
key: K
): T[] => {
return objects.slice().sort((a, b) => {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
return 0;
})
}
sort([{ foo: 10 }, { foo: 2 }], 'foo');
// Argument of type '"bar"' is not assignable to
// parameter of type '"foo"'.(2345)
// V
sort([{ foo: 10 }, { foo: 2 }], 'bar');
Index signatures
The syntax of index signatures is similar to that of mapped types, and their functionalities overlap quite a bit.
Index signatures allow us to model types that have an undefined number of structured key-value pairs. They're useful for representing configuration data, map/cache structures, arrays etc.
type Visited = {
[key: string]: boolean;
}
const visited: Visited = {
node1: true,
node2: false,
};
The square brackets []
syntax looks similar to computed properties on objects. The name of the key can be anything you'd like, and the type must be either string
, number
, or symbol
. You can have at most one index signature of each type, and you can use unions to represent multiple signatures in one go.
type MultipleSignatures = {
// This expands to 2 index signatures.
[key: string | number]: number;
}
When you have both a string
and number
index signature, the number
signature must be a subtype of the string
signature. This is because foo[1]
is converted at runtime to foo['1']
. TypeScript lets us distinguish between the two so we can model more accurate types.
type MixedSignatures = {
// 'number' index type 'string | number' is not assignable
// to 'string' index type 'string'.(2413)
[numberKey: number]: number | string;
[stringKey: string]: string;
}
This restriction doesn't apply to symbol
index signatures.
type MixedSignatures = {
[symbolKey: symbol]: boolean;
[stringKey: string]: string;
}
Literal types and generic types are not allowed, even if they're of type string | number | symbol
:
type Incorrect = {
// An index signature parameter type cannot be a literal type or
// generic type. Consider using a mapped object type instead.(1337)
[literal: 'a' | 'b']: string;
}
Since index signatures allow any number of keys of the given type, TypeScript will let us access arbitrary keys even if they don't exist at runtime:
type Visited = { [city: string]: boolean; }
const visited: Visited = { boston: true };
console.log(visited.boston); // true
// ^? boolean
console.log(visited.london); // undefined
// ^? boolean
To guard against potential mistakes, you can turn on the compiler flag noUncheckedIndexedAccess which will add undefined
to the value type for index signatures, so both properties above would return boolean | undefined
. This can prove especially useful for arrays, to prevent out-of-bounds access:
type NumberArray = {
[K: number]: number;
}
const array: NumberArray = [1, 2, 3];
console.log(array[6]);
// ^? `number | undefined` with the flag on
The flag makes index signatures behave similarly to Map
:
const map = new Map<string, number>();
map.set('foo', 42);
console.log(map.get('foo')); // 42
// ^? `number | undefined`
console.log(map.get('bar')); // undefined
// ^? `number | undefined`
We can combine index signatures with known properties as long as the types match:
type KnownKeysAndSignature = {
[index: string]: number;
// OK, matches the string index signature.
length: number;
// Property 'name' of type 'string' is not assignable
// to 'string' index type 'number'.(2411)
name: string;
}
Using the keyof
operator on an index signature gives us the key type:
type StringMap = {
[key: string]: string
}
type MixedMap = {
[key: number | symbol]: string;
known: string;
}
type K1 = keyof StringMap;
// ^? string | number
type K2 = keyof MixedMap;
// ^? number | symbol | 'known'
You'll notice that keyof
on the string signature gave string | number
, because of the aforementioned conversion of numeric keys to string keys. keyof
will also return any known keys that are mixed with the index signatures.
Indexed access types
We'll use indexed access types later to build mapped types that copy the values from a different type.
Just like we can access a property of an object using the square bracket notation foo['bar']
, we can also look up a specific property on a type:
type Person = {
name: string;
age: number;
employed: boolean;
};
type Age = Person["age"];
// ^? number
We can use unions to get the type of more than one key:
type Person = {
name: string;
age: number;
employed: boolean;
};
type Keys = 'name' | 'age';
type Values = Person[Keys];
// ^? string | number
We can also use the keyof
operator to get the types of all the keys:
type Person = {
name: string;
age: number;
employed: boolean;
};
type Bar = Person[keyof Person];
// ^? string | number | boolean
If we have a type with an index signature we can use the key type to get the value type:
type Visited = {
[key: string]: boolean;
};
type Value = Visited[string];
// ^? boolean
Using an indexed access type on a union will give us a union, as long as all the union members have the type we're accessing:
type Foo = { foo: string }
type Bar = { foo: number }
type Baz = { bar: boolean }
type K1 = (Foo | Bar)['foo']
// ^? string | number
// Property 'foo' does not exist on type 'Foo | Baz'.(2339)
// V
type K2 = (Foo | Baz)['foo']
We can use indexed access types to create a type-safe dispatch
method that changes its payload
type according to the action
:
enum Action { A, B, C }
type Payloads = {
[Action.A]: number;
[Action.B]: { foo: string };
[Action.C]: string;
}
const dispatch = <T extends Action>(
action: T,
payload: Payloads[T]
) => {}
dispatch(Action.A, 42);
dispatch(Action.B, { foo: 'bar' });
// Argument of type 'boolean' is not assignable
// to parameter of type 'string'.(2345)
// V
dispatch(Action.C, false);
We can also use them in combination with generic types to create a function that "plucks" a key from an array of objects and returns the proper value type:
const pluck = <T, K extends keyof T>(
objects: T[],
key: K
): T[K][] => {
return objects.map(o => o[key]);
}
const foos = pluck([{ foo: 1 }, { foo: 2 }], 'foo');
// ^? number[]
Intermission
The previous sections cover important building blocks for mapped types, so let's recap what we've learned so far before continuing:
an index signature
{ [key: number]: number }
can have any number of keys, including 0you can have at most 1 index signature per key type (
number
,string
,symbol
)the
number
index signature must be a subtype of thestring
index signature if both are presentyou can combine an index signature with known keys, as long as they're compatible
the
keyof
operator returns the type of keys for the given type, which will be a subtype ofnumber | string | symbol
we can pick a subset of keys from a type using an indexed access type
Person['age']
type Foo = {
[key: number]: number;
known: string;
}
type Keys = keyof Foo;
// ^? number | 'known'
type Bar = Foo['known'];
// ^? string
Mapped types
Mapped types are types created by iterating (or mapping) over a list of keys. The list of keys can come from a union (like in the example below), a keyof
type, or a generic type parameter.
type Keys = 'a' | 'b';
// { a: number; b: number }
type NumberMap = {
[K in Keys]: number
}
The syntax looks very similar to an index signature, but we have the in
keyword between the name of the key and the type of the key. The type of the key must be string | number | symbol
, just like index signatures, but we can also use literal types. We can still create an index signature with a mapped type, but it doesn't provide any extra value:
type NumberMap = {
// A regular index signature.
[K in number]: number
}
We can also use generic types for the keys, given they're constrained to string | number | symbol
:
type NumberMap<Keys extends string> = {
[K in Keys]: number;
}
const map: NumberMap<'a' | 'b'> = {
a: 1,
b: 2
};
Notice how our constraint for the keys is string
, not string[]
. You can think of in
as iterating over a list, but the type has to be a union, not an array. If we use the keyof
operator over the generic type parameter then that constraint is automatically taken care of, as the operator will always return string | number | symbol
:
type Person = {
name: string;
age: number;
}
type Numberify<T> = {
[K in keyof T]: number
}
type Baz = Numberify<Person>;
// ^? { name: number; age: number }
The example above iterates over the keys of one type and changes the value type to number
. We can use an indexed access type to preserve the original value type:
type FormValues<T> = {
[K in keyof T]: {
// Get the type in `T` for the value at key `K`.
// VV
value: T[K];
label: string;
}
}
type Person = { name: string; age: number }
// ^ ^
// | |
// | +-------+
// | |
// +-----------------+ |
// | |
const options: FormValues<Person> = { // | |
// +----------`string`-------------------+ |
// | | |
// V V |
name: { value: 'Bob', label: 'Name' }, // |
// +--------`number`-------------------------+
// | |
// V V
age: { value: 42, label: 'Age' },
}
Using all of the above we can re-create TypeScript's built-in Record
and Pick
types:
type Record<K extends string | number | symbol, V> = {
[Key in K]: V
}
const map: Record<'age' | 'height', number> = {
age: 42,
height: 180
};
type Pick<T, K extends keyof T> = {
[Key in K]: T[Key];
}
type Bar = Pick<Person, 'age'>;
// ^? { age: number }
The K extends keyof T
constraint means that the list of keys we pass in must be a subset of all the keys of T
. This prevents us from trying to pick an incorrect key.
// Type '"oops"' does not satisfy the constraint 'keyof Foo'.(2344)
// VV
type Bar = Pick<Person, 'oops'>;
Mapping modifiers
When mapping keys we can change their mutability and/or their optionality with the readonly
and ?
modifiers. We can remove the modifiers with -
, or add them with +
. +
is assumed by default so we can leave it out.
type Person = {
name: string;
readonly born: Date;
died?: Date;
}
The above type contains a mix of immutable and optional properties, so let's see how we can change them using a mapped type.
type Partial<T> = {
[K in keyof T]?: T[K];
}
type MaybePerson = Partial<Person>;
// ^? { name?: string; readonly born?: Date; died?: Date }
The first example showcases the built-in Partial
type which makes all mapped properties optional.
type Readonly<T> = {
readonly [K in keyof T]: T[K];
}
type ReadonlyPerson = Readonly<Person>;
// ^? { readonly name: string; readonly born: Date; readonly died?: Date }
Here we make all mapped properties immutable using the built-in Readonly
type.
type Required<T> = {
[K in keyof T]-?: T[K];
};
type RequiredPerson = Required<Person>;
// ^? { name: string; readonly born: Date; died: Date }
This example removes the optionality with the built-in Required
type.
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
}
type MutablePerson = Mutable<Person>;
// ^? { name: string; born: Date; died?: Date }
The above example removes the immutability of all mapped properties and is the only type that's not built-in.
type MutableAndRequired<T> = {
-readonly [K in keyof T]-?: T[K];
}
type CleanedUpPerson = MutableAndRequired<Person>;
// ^? { name: string; born: Date; died: Date }
Lastly, the above example shows how we can mix both modifiers at the same time.
Homomorphic mapped types
Whenever a mapped type uses keyof
to get the list of keys, or a generic type parameter with an extends keyof
constraint, it becomes a homomorphic mapped type. This gives it a few interesting properties, the first being that TypeScript will preserve the property modifiers from the input type:
type Person = {
name: string;
readonly born: Date;
died?: Date;
}
// modifiers are copied over
// | |
// V V
// { name: string; readonly born: Date; died?: Date }
type HomomorphicMapping = {
[K in keyof Person]: Person[K];
}
Compare the above with the non-homomorphic mapped type below which hardcodes the list of keys:
// modifiers are not copied over
// | |
// V V
// { name: string; born: Date; died: Date }
type NonHomomorphicMapping = {
[K in 'name' | 'born' | 'died']: Person[K];
}
Even though we're still iterating over the keys from the Person
type, and we're using the same indexed access type for the values, TypeScript no longer sees the relationship between the input type Person
and our mapping. Non-homomorphic types are essentially creating new properties, so they can’t copy property modifiers from anywhere.
The other property homomorphic mapped types have is they preserve tuples and arrays instead of converting them to object types:
type Coordinate = [number, number];
type HomomorphicPromisify<T> = {
[K in keyof T]: Promise<T[K]>;
};
type PromiseCoordinate = HomomorphicPromisify<Coordinate>;
// ^? [Promise<number>, Promise<number>]
type Length = PromiseCoordinate['length'];
// ^? 2
Notice how the length
property was kept as number
, and not mapped to Promise<number>
by our mapped type. Even though keyof
on arrays and tuples return the numeric keys and methods and properties such as length
, homomorphic types will not apply any transformations to them.
TypeScript has a bug (microsoft/TypeScript#27995) that treats mapping over non-generic tuples as non-homomorphic.
Moreover, another bug microsoft/TypeScript#/27351 may prevent using an indexed access type over constrained arrays.
Lastly, homomorphic mapped types can be "unwrapped" to get the original type, even if the new type only copied a subset of keys, such as in the case of Pick
:
const unwrap = <T, K extends keyof T>(partial: Pick<T, K>): T => {}
const partial: Pick<Person, 'name'> = {
name: 'Bob'
}
const original = unwrap(partial);
// ^? Person
In general, most mapped types you would create, or come across, would be homomorphic. The built-in Partial
, Pick
, Readonly
and Required
types are homomorphic, while Record
is not, because it receives the set of keys directly, rather than copying them from another type.
Key remapping
All of the previous examples keep the original keys from the type we're iterating on, but we can also change them using the as
operator. Together with indexed access types, we can "re-index" a nested object by a different key:
type Analytics = Record<string, { title: string; views: number }>;
type ViewsByTitle<T extends Analytics> = {
[K in keyof T as T[K]['title']]: T[K]['views']
}
// { 'Home page': 42 }
type Bar = ViewsByTitle<{
index: {
title: 'Home page',
views: 42
}
}>
We can also use template literal types to rename the keys (stay tuned for a future episode that will cover them in more detail):
type Prefix<T> = {
[K in keyof T as `base_${K & string}`]: T[K];
}
type Bar = Prefix<{ foo: string }>
// ^? { base_foo: string }
Summary
Thank you for reading and I hope you enjoyed the article. Below you can find the key takeaways and a shareable sketch note. Make sure to catch the next episode TypeScript Thursday - S01E02, which will explore ways of filtering out certain keys in mapped types!
If you liked this article, maybe you'd also like some of my open-source projects at github.com/NiGhTTraX.
Takeaways:
index signatures create types with any number of keys
they look like objects and use the square bracket
[]
syntax to denote the key typethe
keyof
operator returns the union of keys for the given typean index signature
{ [key: number]: number }
can have any number of keys, including 0we can pick a subset of keys from a type using an indexed access type
a mapped type constructs a type from a union of keys all having the same value type
the syntax is similar to that of index signatures, but it adds the
in
keywordindex signatures always have "infinite" keys, mapped types can have "finite" keys
you can use generic types and/or
keyof
to get the keys from another typea mapped type is homomorphic if it copies all or a subset of keys from another type with
keyof
homomorphic mapped types preserve property modifiers, preserve tuples and arrays, and can be unwrapped to the original type
you can add or remove
readonly
and?
property modifiers with+
and-
TypeScript ships with
Record
,Pick
,Partial
,Readonly
andRequired
types