Typescript Explained In Javascript: Mapped Types
3rd post in "TypeScript explained in JavaScript" series
Spencer Miskoviak
June 6, 2020
Photo by Kelsey Knight
The previous two posts covered the keyof
operator and extends
keyword. This
is the first post to begin building on top of these concepts to explore more
complex typings.
JavaScript
Before talking about what mapped types actually are, let's start with the
concept of "mapping" in JavaScript. Hearing this, you might
think of JavaScript's Array map
function.
const list = [1, 2, 3, 4];
const double = value => value * 2;
const doubled = list.map(double);
console.log(doubled); // [ 2, 4, 6, 8 ]
Here, we were able to take a list of numbers and double every value. The map
function will call the double
function for every item and pass in the value
from the list
. The value returned will be the new value in the final doubled
array. This works well for arrays, but how is something like this achieved
with objects? There are a few ways.
const list = {
one: 1,
two: 2,
three: 3,
four: 4
};
const evens = {};
for (const key in list) {
if (list[key] % 2 === 0) {
evens[key] = true;
} else {
evens[key] = false;
}
}
This first approach uses a
for...in
statement
to iterate through all the enumerable properties of the list object. It builds a new
object evens
with the same keys as list
but each value is now a boolean
indicating whether or not it is even. The resulting evens
object will look
like the following.
{ "one": false, "two": true, "three": false, "four": true }
Another approach is to use the Object.keys
method discussed in the first
post exploring keyof
. This
will return an array of the keys which can then be iterated through using
the Array forEach
function.
It works very similar to the map
function above, but it doesn't return anything.
const evens = {};
Object.keys(list).forEach(key => {
if (list[key] % 2 === 0) {
evens[key] = true;
} else {
evens[key] = false;
}
});
Now that we know how mapping and iterating over arrays and objects looks in JavaScript, what could this look like with some types?
TypeScript
To start, let's create an interface to describe the shape of the list
object.
interface List {
one: number;
two: number;
three: number;
four: number;
}
const list: List = {
one: 1,
two: 2,
three: 3,
four: 4
};
Now, taking the last JavaScript snippet directly from above results in two issues.
Both are: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type ...
.
The first is a result of using Object.keys
. It returns string[]
but the
list
only allows four specific strings: one
, two
, three
, four
. The easiest fix
is to cast the result. Another option is to define a method that uses
Object.keys
but with stricter typings.
For now, let's cast it to be an array of only the keys contained within the list.
const keys = Object.keys(list) as Array<keyof List>;
keys.forEach(key => {
if (list[key] % 2 === 0) {
evens[key] = true;
} else {
evens[key] = false;
}
});
The other issue is with evens
. It's initialized as {}
which means it's
inferred typed is an empty object with no keys. How do we type this to say it
has the same keys as the list
, but boolean
values instead of number
?
This is where mapped types come in. The first step in JavaScript was to use
Object.keys
to get an array of keys.
As covered in the first post
keyof
is roughly the equivalent operator in the type system. So we now
have a union of the keys with keyof List
. The next step in JavaScript was to
iterate through these values. Unlike JavaScript, there's only one way to do this
with types and it's most similar to the for...in
syntax.
type Evens = { [Key in keyof List]: boolean };
The outer curly braces are saying this is an object. Within the square brackets
is the equivalent to Object.keys(list).forEach(...)
. It's saying that for each
key (Key
) in (in
) the union of keys (keyof List
) it's value is a boolean
(boolean
).
There is one more issue though. To start, evens
is empty. One way to work
around this is to say all the keys are optional. This can be achieved by marking
every field as optional with a question mark.
type Evens = { [Key in keyof List]?: boolean };
Expanded out, this is now equivalent to the following.
type Evens = {
one?: boolean;
two?: boolean;
three?: boolean;
four?: boolean;
};
Now the type can be used and all type issues are solved!
const evens: Evens = {};
This simple example could also as easily be done by hand without the use of
mapped types. However, the benefits of mapped types become more apparent when working with large or complex typings.
Additionally, it keeps the derived type in-sync so if five
is added to List
,
Evens
will also immediately include it. Finally, in combination with generics,
they can be reused. For example, what if there were many of these lists?
interface ListTwo {
five: number;
six: number;
seven: number;
}
The same logic could be duplicated, but since it needs to be reused many times a type that accepts an "argument" (generic) that is the list can be used.
type Boolify<Input> = { [Key in keyof Input]?: boolean };
type Evens = Boolify<List>;
type EvensTwo = Boolify<ListTwo>;
This can be broken down even further. What if it accepted any keys and their value?
type Record<Keys extends string | number | symbol, Value> = {
[Key in Keys]: Value;
};
type Evens = Record<keyof List, boolean>;
type EvensTwo = Record<keyof ListTwo, boolean>;
Now Record
can be used for any keys and given value.
type Example = Record<"a" | "b" | "c", number>;
// Results in:
// {
// a: number;
// b: number;
// c: number;
// }
TypeScript actually already defines Record
along with many other
utility types
such as Partial
for making all the properties optional.
Mapped types are fairly flexible, so there are a number of ways the types could
be written. For example, with Record
and Partial
it could look like the following.
interface List {
one: number;
two: number;
three: number;
four: number;
}
const list: List = {
one: 1,
two: 2,
three: 3,
four: 4
};
const keys = Object.keys(list) as Array<keyof List>;
type Evens = Partial<Record<keyof List, boolean>>;
const evens: Evens = {};
keys.forEach(key => {
if (list[key] % 2 === 0) {
evens[key] = true;
} else {
evens[key] = false;
}
});
Definition
The TypeScript documentation also provides a more formal and succinct definition.
In a mapped type, the new type transforms each property in the old type in the same way.
The mapped type syntax has three parts:
- The type variable
Key
, which gets bound to each property in turn. - The string union
keyof List
, which contains the names of properties to iterate over ("one"
,"two"
,"three"
,"four"
). - The resulting type of the property (
boolean
).
Conclusion
In summary, mapped types behave in a conceptually similar way to mapping
over an array or using a for..in
statement in JavaScript. They are an
invaluable tool in the typing tool belt. They help derive complex types from
other complex types, avoid duplication, and guarantee types will stay in-sync.
Tags:
course
Practical Abstract Syntax Trees
Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.
Check out the course