Learn TypeScript w/ Mike North

Mapped Types

June 10, 2021

Table of Contents

Mapped allow types to be defined in other types through a much more flexible version of an index signature. We’ll study this type in detail, and demonstrate how it makes language features like indexed access types and conditional types even more powerful!

The basics

If you recall the concept of index signatures allow specification of some value type for an arbitrary key. They’re the foundation for dictionary types in TypeScript:

ts
type Fruit = {
name: string
color: string
mass: number
}
 
type Dict<T> = { [k: string]: T } // <- index signature
 
const fruitCatalog: Dict<Fruit> = {}
fruitCatalog.apple
Fruit
Try

What if we didn’t want just any key to be used to store fruits in our fruitCatalog object, but a specific subset of keys.

You could do this with a map type. We’ll stop calling our collection a Dict, since that would imply that we could still use arbitrary keys. Let’s call this a Record instead. How about MyRecord

ts
type Fruit = {
name: string
color: string
mass: number
}
 
// mapped type
type MyRecord = { [FruitKey in "apple" | "cherry"]: Fruit }
 
function printFruitCatalog(fruitCatalog: MyRecord) {
fruitCatalog.cherry
fruitCatalog.apple
(property) apple: Fruit
fruitCatalog.pineapple
Property 'pineapple' does not exist on type 'MyRecord'.2339Property 'pineapple' does not exist on type 'MyRecord'.
}
Try

The thing that looks like an index signature is what makes this a mapped type:

ts
{ [FruitKey in "apple" | "cherry"]: ... }

Let’s compare this to a true index signature so that we can see the differences

ts
{ [nameDoesntMatter: string]: ... }

Notice:

  • The in keyword in the mapped type
  • Index signatures can be on all strings or all numbers, but not some subset of strings or numbers
ts
type MyRecord = { [key: "apple" | "cherry"]: Fruit }
An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.1337An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
Try

Record

If make our type a bit more generalized with some type params instead of hardcoding Fruit and "apple" | "cherry" as shown below, we’ll arrive at a built-in utility type that comes with TypeScript.

diff
- type MyRecord = { [FruitKey in "apple" | "cherry"]: Fruit }
+ type MyRecord<KeyType, ValueType> = { [Key in KeyType]: ValueType }
ts
type MyRecord<KeyType extends string, ValueType> = {
[Key in KeyType]: ValueType
}
Try

Here’s the built-in TypeScript type, which matches this pretty much exactly:

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

You may notice the keyof any difference. As you can see below, it’s just string | number | symbol

ts
let anyKey: keyof any
let anyKey: string | number | symbol
Try

Use with indexed access types

Mapped types work beautifully with indexed access types, because the index can be used when defining the value type.

ts
type PartOfWindow = {
type PartOfWindow = { document: Document; navigator: Navigator; setTimeout: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number; }
[Key in
| "document"
| "navigator"
| "setTimeout"]: Window[Key]
}
Try

We couldn’t have written any equivalent of Window[Key] using regular index signatures. What we end up with, in a sense, is almost as if the mapped type loops over all of the possible keys, and determines the appropriate value type for each key

Let’s make this a little more generalized through the use of type params. First, we should let the caller define which keys they’d like to use. We’ll call this type PickWindowProperties because we get to specify which things from Window we’d like

ts
type PickWindowProperties<Keys extends keyof Window> = {
[Key in Keys]: Window[Key]
}
type PartOfWindow = PickWindowProperties<
"document" | "navigator" | "setTimeout"
type PartOfWindow = { document: Document; navigator: Navigator; setTimeout: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number; }
>
Try

Let’s generalize it one step further by allowing this type to work on anything, not just a Window. Because this is no longer a type that exclusively works with Window, we’ll rename this type to PickProperties.

ts
type PickProperties<
ValueType,
Keys extends keyof ValueType
> = {
[Key in Keys]: ValueType[Key]
}
type PartOfWindow = PickProperties<
Window,
type PartOfWindow = { readonly document: Document; readonly navigator: Navigator; setTimeout: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number; }
"document" | "navigator" | "setTimeout"
>
Try

Pick

We’ve arrived at another built-in TypeScript utility type: Pick. Our PickProperties now matches it exactly (the names of our type params are not of consequence)

ts
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}
ts
type PickProperties<
ValueType,
Keys extends keyof ValueType
> = {
[Key in Keys]: ValueType[Key]
}
Try

Mapping modifiers

Following our analogy of mapped types feeling like “looping over all keys”, there are a couple of final things we can do to the properties as we create each type: set whether the value placed there should be readonly and/or optional

This is fairly straightforward, and you can see the use of the ? and readonly in the three more built-in TypeScript utility types below.

If there’s a - to the left of readonly or ? in a mapped type, that indicates removal of this modifier instead of application of the modifier.

ts
/**
* Make all properties in T optional
*/
type Partial<T> = {
[P in keyof T]?: T[P]
}
/**
* Make all properties in T required
*/
type Required<T> = {
[P in keyof T]-?: T[P]
}
/**
* Make all properties in T readonly
*/
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}

There is no built-in TypeScript “utility type” for readonly removal, but you could implement one if you needed to (not necessarily a good idea though)

ts
type NotReadonly<T> = {
-readonly [P in keyof T]: T[P]
}
Try

Template literal types

TypeScript 4.1 brought with it template literal types.

Below you can see an example of how we take three things you could find in a painting, and four paint colors you could use.

ts
type ArtFeatures = "cabin" | "tree" | "sunset"
type Colors =
| "darkSienna"
| "sapGreen"
| "titaniumWhite"
| "prussianBlue"
Try

We can use the exact same syntax that one would find in an ECMAScript template literal, but in a type expression instead of a value expression, to create a new type that represents every possible combination of these art features and colors

ts
type ArtMethodNames = `paint_${Colors}_${ArtFeatures}`
type ArtMethodNames = "paint_darkSienna_cabin" | "paint_darkSienna_tree" | "paint_darkSienna_sunset" | "paint_sapGreen_cabin" | "paint_sapGreen_tree" | "paint_sapGreen_sunset" | "paint_titaniumWhite_cabin" | ... 4 more ... | "paint_prussianBlue_sunset"
Try

While something like "paint_darkSienna_cabin" could definitely be the name of a class method in JavaScript or TypeScript, it’s more conventional to use camelCase instead of snake_case

TypeScript provides a few special types you can use within these template literal types

  • UpperCase
  • LowerCase
  • Capitalize
  • Uncapitalize
ts
type ArtMethodNames =
`paint${Capitalize<Colors>}${Capitalize<ArtFeatures>}`
type ArtMethodNames = "paintDarkSiennaCabin" | "paintDarkSiennaTree" | "paintDarkSiennaSunset" | "paintSapGreenCabin" | "paintSapGreenTree" | "paintSapGreenSunset" | "paintTitaniumWhiteCabin" | ... 4 more ... | "paintPrussianBlueSunset"
Try

There we go. paintDarkSiennaCabin is much more aligned with what we’re used to seeing for function names.

Now, let’s bring this back into the world of Mapped Types, to perform some key mapping, where the resultant Mapped Type has different property names than the type being “iterated over” during the mapping.

Note the use of the as keyword in the index signature

ts
interface DataState {
digits: number[]
names: string[]
flags: Record<"darkMode" | "mobile", boolean>
}
 
type DataSDK = {
// The mapped type
[K in keyof DataState as `set${Capitalize<K>}`]:
(arg: DataState[K]) => void
}
 
function load(dataSDK: DataSDK) {
dataSDK.setDigits([14])
dataSDK.setFlags({ darkMode: true, mobil: false })
Argument of type '{ darkMode: true; mobil: boolean; }' is not assignable to parameter of type 'Record<"darkMode" | "mobile", boolean>'. Object literal may only specify known properties, but 'mobil' does not exist in type 'Record<"darkMode" | "mobile", boolean>'. Did you mean to write 'mobile'?2345Argument of type '{ darkMode: true; mobil: boolean; }' is not assignable to parameter of type 'Record<"darkMode" | "mobile", boolean>'. Object literal may only specify known properties, but 'mobil' does not exist in type 'Record<"darkMode" | "mobile", boolean>'. Did you mean to write 'mobile'?
}
Try

If you’ve ever written data layer code, where often there are defined types available, and potentially you have a lot of is*, get* and set* methods, you’re probably starting to see how Mapped Types have the potential to provide rich validation across a wide range of data models.

Filtering properties out

We’ve already seen how we could filter properties out of a mapped type, if the filter condition is based on the key.

Here’s an example using Extract and a template literal type to filter for only those members of window.document that begin with "query":

ts
type DocKeys = Extract<keyof Document, `query${string}`>
type KeyFilteredDoc = {
type KeyFilteredDoc = { queryCommandEnabled: (commandId: string) => boolean; queryCommandIndeterm: (commandId: string) => boolean; queryCommandState: (commandId: string) => boolean; queryCommandSupported: (commandId: string) => boolean; queryCommandValue: (commandId: string) => string; querySelector: { ...; }; querySelectorAll: { ...; }; }
[K in DocKeys]: Document[K]
}
Try

But what if we needed to filter by value? To put this another way, what if we wanted things to be included or excluded from our mapped type based on Document[K]?

Our solution has to do with never and conditional types.

Here we’re using a flawed approach, where we set the “type of the value” to never whenever we want to skip it. This is going to leave us with a type that still has 100% of the keys that Document has, with many many values of type never

ts
///////////////////////////////////////////////////////////
// EXAMPLE OF WHAT NOT TO DO. DO NOT FOLLOW THIS EXAMPLE //
///////////////////////////////////////////////////////////
type ValueFilteredDoc = {
type ValueFilteredDoc = { readonly URL: never; alinkColor: never; readonly all: never; readonly anchors: never; readonly applets: never; bgColor: never; body: never; readonly characterSet: never; readonly charset: never; ... 245 more ...; evaluate: never; }
[K in keyof Document]: Document[K] extends (
...args: any[]
) => Element | Element[]
? Document[K]
: never
}
 
function load(doc: ValueFilteredDoc) {
doc.querySelector("input")
(method) querySelector<"input">(selectors: "input"): HTMLInputElement | null (+2 overloads)
}
Try

Click Try and poke at this code in the TypeScript playground. While we’re kind of “blocked” from using the things we tried to omit in our mapped type, this is quite messy.

A better approach, which will get us a much cleaner result is to filter our keys first and then use those keys to build a mapped type

ts
// Get keys of type T whose values are assignable to type U
type FilteredKeys<T, U> = {
[P in keyof T]: T[P] extends U ? P : never
}[keyof T] &
keyof T
 
type RelevantDocumentKeys = FilteredKeys<
Document,
(...args: any[]) => Element | Element[]
>
 
type ValueFilteredDoc = Pick<Document, RelevantDocumentKeys>
type ValueFilteredDoc = { adoptNode: <T extends Node>(node: T) => T; createElement: { <K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions | undefined): HTMLElementTagNameMap[K]; <K extends keyof HTMLElementDeprecatedTagNameMap>(tagName: K, options?: ElementCreationOptions | undefined): HTMLElementDeprecatedTagNameMap[K]; (tagName: string, options?: ElementCreationOptions | undefined): HTMLElement; }; ... 7 more ...; querySelector: { ...; }; }
 
function load(doc: ValueFilteredDoc) {
doc.querySelector("input")
(method) querySelector<"input">(selectors: "input"): HTMLInputElement | null (+2 overloads)
}
Try


© 2022 All Rights Reserved