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:
tsTry
typeFruit = {name : stringcolor : stringmass : number}typeDict <T > = { [k : string]:T | undefined } // <- index signatureconstfruitCatalog :Dict <Fruit > = {}fruitCatalog .apple
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
…
tsTry
typeFruit = {name : stringcolor : stringmass : number}// mapped typetypeMyRecord = { [FruitKey in "apple" | "cherry"]:Fruit }functionprintFruitCatalog (fruitCatalog :MyRecord ) {fruitCatalog .cherry fruitCatalog .apple Property 'pineapple' does not exist on type 'MyRecord'.2339Property 'pineapple' does not exist on type 'MyRecord'.fruitCatalog .pineapple }
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
string
s, allnumber
s, allSymbol
s, but not some specific subset of these primitive types
tsTry
typeAn 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.MyRecord = { [: "apple" | "cherry"]: key Fruit }
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.
tsTry
typeAnyPossibleKey = keyof any
diff
- type MyRecord = { [FruitKey in "apple" | "cherry"]: Fruit }+ type MyRecord<K extends keyof any, V> = { [Key in K]: V }
tsTry
typeMyRecord <K extends keyof any,V > = { [Key inK ]:V }
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
tsTry
typeMyRecord <K extends keyof any,V > = { [Key inK ]:V }
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.
tsTry
typePartOfWindow = {[Key in| "document"| "navigator"| "setTimeout"]:Window [Key ]}
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
tsTry
typePickWindowProperties <Keys extends keyofWindow > = {[Key inKeys ]:Window [Key ]}typePartOfWindow =PickWindowProperties <"document" | "navigator" | "setTimeout">
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
.
tsTry
typePickProperties <ValueType ,Keys extends keyofValueType > = {[Key inKeys ]:ValueType [Key ]}typePartOfWindow =PickProperties <Window , "document" | "navigator" | "setTimeout">
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]}
tsTry
typePickProperties <ValueType ,Keys extends keyofValueType > = {[Key inKeys ]:ValueType [Key ]}
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.
tsTry
typeNotReadonly <T > = {-readonly [P in keyofT ]:T [P ]}
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.
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.
tsTry
typeArtFeatures = "cabin" | "tree" | "sunset"typeColors =| "darkSienna"| "sapGreen"| "titaniumWhite"| "prussianBlue"
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
tsTry
typeArtMethodNames = `paint_${Colors }_${ArtFeatures }`
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
tsTry
typeArtMethodNames =`paint${Capitalize <Colors >}${Capitalize <ArtFeatures >}`
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
tsTry
interfaceDataState {digits : number[]names : string[]flags :Record <"darkMode" | "mobile", boolean>}typeDataSDK = {// The mapped type[K in keyofDataState as `set${Capitalize <K >}`]:(arg :DataState [K ]) => void}functionload (dataSDK :DataSDK ) {dataSDK .setDigits ([14])dataSDK .setFlags ({darkMode : true,mobile : false })}
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
tsTry
constcourseWebsite = "Frontend Masters";typeExtractMasterName <S > =S extends `${inferT } Masters` ?T : never;letType '"Backend"' is not assignable to type '"Frontend"'.2322Type '"Backend"' is not assignable to type '"Frontend"'.: fe ExtractMasterName <typeofcourseWebsite > = 'Backend'
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"
:
tsTry
typeDocKeys =Extract <keyofDocument , `query${string}`>typeKeyFilteredDoc = {[K inDocKeys ]:Document [K ]}
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
tsTry
///////////////////////////////////////////////////////////// EXAMPLE OF WHAT NOT TO DO. DO NOT FOLLOW THIS EXAMPLE /////////////////////////////////////////////////////////////typeValueFilteredDoc = {[K in keyofDocument ]:Document [K ] extends (...args : any[]) =>Element |Element []?Document [K ]: never}functionload (doc :ValueFilteredDoc ) {doc .querySelector ("input")}
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
tsTry
// Get keys of type T whose values are assignable to type UtypeFilteredKeys <T ,U > = {[P in keyofT ]:T [P ] extendsU ?P : never}[keyofT ] &keyofT typeRelevantDocumentKeys =FilteredKeys <Document , (...args : any[]) =>(Element |Element []) >typeValueFilteredDoc =Pick <Document ,RelevantDocumentKeys >functionload (doc :ValueFilteredDoc ) {doc .querySelector ("input")}