Learn TypeScript w/ Mike North

Mapped Types

October 25, 2023

Table of Contents

Mapped types are a powerful feature in TypeScript that allows you to create new types based on existing ones by transforming properties in a controlled manner. Think of this feature kind of like array.map(), but for types instead of values.

The basics

Recall that 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 | undefined } // <- index signature
 
const fruitCatalog: Dict<Fruit> = {}
fruitCatalog.apple
Fruit | undefined
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 mapped 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 define keys as all strings, all numbers, all Symbols, but not some specific subset of these primitive types
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 we’ll arrive at a built-in utility type that comes with TypeScript.

We just need to…

  • Replace the hardcoded Fruit and "apple" | "cherry" with typeParams
  • Replace the string type with a type that represents any possible property name in JavaScript.
ts
type AnyPossibleKey = keyof any
type AnyPossibleKey = string | number | symbol
Try
diff
- type MyRecord = { [FruitKey in "apple" | "cherry"]: Fruit }
+ type MyRecord<K extends keyof any, V> = { [Key in K]: V }
ts
type MyRecord<K extends keyof any, V> = { [Key in K]: V }
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
}

Use with indexed access types

Note that we’ve introduced a Key term in the mapped type

ts
type MyRecord<K extends keyof any, V> = { [Key in K]: V }
(type parameter) Key
Try

This is a newly created typeParam that happens in mapped types, which can be used as part of the type expression for the value associated with the key. Indexed access types work beautifully with this new typeParam.

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 =
type PartOfWindow = { document: Document; navigator: Navigator; setTimeout: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number; }
PickWindowProperties<"document" | "navigator" | "setTimeout">
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 =
type PartOfWindow = { readonly document: Document; readonly navigator: Navigator; setTimeout: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number; }
PickProperties<Window, "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 it’s possible to implement one.

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

Given this kind of utility type introduces mutability to values that someone intended to be immutable, and the readonly keyword is a compile-time-only concept that in no way prevents writing to the value at runtime, there’s a good reason that TypeScript doesn’t include this one.

male plug to male plug electrical warning

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, mobile: false })
}
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.

Extracting string literal types

TypeScript 5 allows infer to be used in combination with string template types, which we can use to effectively extract portions of strings as new string literal types

ts
const courseWebsite = "Frontend Masters";
 
type ExtractMasterName<S> = S extends `${infer T} Masters` ? T : never;
 
let fe: ExtractMasterName<typeof courseWebsite> = 'Backend'
Type '"Backend"' is not assignable to type '"Frontend"'.2322Type '"Backend"' is not assignable to type '"Frontend"'.
let fe: "Frontend"
Try

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; ... 249 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 (+4 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 (+4 overloads)
}
Try


© 2023 All Rights Reserved