Editor’s note: This article was last updated on 8 November 2022 to add information on TypeScript utility types, like Partial<Type>
, Pick<Type, Keys>
, and Readonly<Type>
.
Mapped types are a handy TypeScript feature that allow authors to keep their types DRY (“Don’t Repeat Yourself”). However, because they toe the line between programming and metaprogramming, mapped types can be difficult to understand at first.
In this post, we’ll cover:
Using mapped types in a program is especially useful when there is a need for a type to be derived from (and remain in sync with) another type:
// Configuration values for the current user type AppConfig = { username: string; layout: string; }; // Whether or not the user has permission to change configuration values type AppPermissions = { changeUsername: boolean; changeLayout: boolean; };
This example is problematic because there is an implicit relationship between AppConfig
and AppPermissions
. Whenever a new configuration value is added to AppConfig
, there must also be a corresponding boolean
value in AppPermissions
.
It is better to have the type system manage this relationship than to rely on the discipline of future program editors to make the appropriate updates to both types simultaneously.
We’ll delve into the specifics of the mapped types syntax later on, but here is a preview of the same example using mapped types instead of explicit types:
// Configuration values for the current user type AppConfig = { username: string; layout: string; }; // Whether or not the user has permission to change configuration values type AppPermissions = { [Property in keyof AppConfig as `change${Capitalize<Property>}`]: boolean }; let permission:AppPermissions = { }
TypeScript will throw the following error:
Adding changeUsername
and changeLayout
to the permission
variable as follows will resolve this error:
let permission:AppPermissions = { changeLayout: true, changeUsername: false }
Mapped types build upon each of these concepts and TypeScript features.
In a computer science context, the term “map” means to transform one thing into another, or, more commonly, refers to turning similar items into a different list of transformed items. Likely the most familiar application of this idea is Array.prototype.map()
, which is used in everyday TypeScript and JavaScript programming:
[1, 2, 3].map(value => value.toString()); // Yields ["1", "2", "3"]
Here, we’ve mapped each number in the array to its string representation. So a mapped type in TypeScript means we’re taking one type and transforming it into another type by applying a transformation to each of its properties.
TypeScript authors can access the type of a property by looking it up by name:
type AppConfig = { username: string; layout: string; }; type Username = AppConfig["username"];
In this case, the resolved type of Username
is string
. For more information on indexed access types, see the official docs.
Index signatures are handy for cases when the actual names of the type’s properties are not known, but the type of data they will reference is known:
type User = { name: string; preferences: { [key: string]: string; } }; const currentUser: User = { name: 'Foo Bar', preferences: { lang: 'en', }, }; const currentLang = currentUser.preferences.lang;
In this example, the TypeScript compiler reports that the type of currentLang
is string
rather than any
. This functionality, in conjunction with the keyof
operator detailed below, is one of the core components that make mapped types possible. For more information on index signatures, see the official documentation on object types.
A union type is a combination of two or more types. It signals to the TypeScript compiler that the type of the underlying value could be any one of the types included in the union. This is a valid TypeScript program:
type StringOrNumberUnion = string | number; let value: StringOrNumberUnion = 'hello, world!'; value = 100;
Here is a more complicated example that shows some of the advanced protection the compiler can offer with union types:
type Animal = { name: string; species: string; }; type Person = { name: string; age: number; }; type AnimalOrPerson = Animal | Person; const value: AnimalOrPerson = loadFromSomewhereElse(); console.log(value.name); // No problem, both Animal and Person have the name property. console.log(value.age); // Compilation error; value might not have the age property if it is an Animal. if ('age' in value) { console.log(value.age); // No problem, TS knows that value has the age property, and therefore it must be a Person if we're inside this if block. }
See the docs on everyday types for more information on union types.
keyof
type operatorThe keyof
type operator returns a union of the keys of the type passed to it. For example:
type AppConfig = { username: string; layout: string; }; type AppConfigKey = keyof AppConfig;
The AppConfigKey
type resolves to "username" | "layout"
. Note that this also works in tandem with index signatures:
type User = { name: string; preferences: { [key: string]: string; } }; type UserPreferenceKey = keyof User["preferences"];
The UserPreferenceKey
type resolves to string | number
(number
because accessing JavaScript object properties by number is valid syntax). Read about the keyof
type operator here.
Though not strictly required for implementing mapped types, tuples are a common construct in typical TypeScript programs. Tuples are a special kind of array type, where the elements of the array may be of specific types at specific indices. They allow the TypeScript compiler to provide greater safety around arrays of values, particularly when those values are of different types.
For example, the TypeScript compiler is able to provide type safety for the various elements of the tuple:
type Currency = [number, string]; const amount: Currency = [100, 'USD']; function add(values: number[]) { return values.reduce((a, b) => a + b); } add(amount); // Error: Argument of type 'Currency' is not assignable to parameter of type 'number[]'. // Type 'string' is not assignable to type 'number'.
Try this code in the TypeScript playground.
TypeScript is also able to alert us when accessing an element at an index beyond the tuple’s defined types:
type LatLong = [number, number]; // Note that we're not using number[] here. const loc: LatLong = [48.858370, 2.294481]; console.log(loc[2]); // Error: Tuple type 'LatLong' of length '2' has no element at index '2'.
Try this code in the TypeScript playground.
Now that we’ve covered the foundations upon which TypeScript’s mapped types feature is built, let’s walk through a detailed real-world example. Suppose our program keeps track of electronic devices and their manufacturers and prices. We might have a type like this to represent each device:
type Device = { manufacturer: string; price: number; };
Now, we’d like to ensure that we have a way to display those devices to the user in a human-readable format, so we’ll add a new type for an object that can format each property of a Device
with an appropriate formatting function:
type DeviceFormatter = { [Key in keyof Device as `format${Capitalize<Key>}`]: (value: Device[Key]) => string; };
Let’s pull this code block apart, piece by piece.
Key in keyof Device
uses the keyof
type operator to generate a union of all keys in Device
. Putting it inside of an index signature essentially iterates through all properties of Device
and maps them to properties of DeviceFormatter
.
format${Capitalize<Key>}
is the transformation part of the mapping and uses key remapping and template literal types to change the property name from x
to formatX
.
(value: Device[Key]) => string;
is where we use the indexed access type Device[Key]
to indicate that the format function’s value
parameter is of the type of the property we are formatting. So, formatManufacturer
takes a string
(the manufacturer) while formatPrice
takes a number
(the price).
Here’s what the DeviceFormatter
type looks like:
type DeviceFormatter = { formatManufacturer: (value: string) => string; formatPrice: (value: number) => string; };
Now, let’s suppose we add a third property, releaseYear
, to our Device
type:
type Device = { manufacturer: string; price: number; releaseYear: number; }
Thanks to the power of mapped types, the DeviceFormatter
type is automatically expanded to look like this without any additional work on our part:
type DeviceFormatter = { formatManufacturer: (value: string) => string; formatPrice: (value: number) => string; formatReleaseYear: (value: number) => string; };
Any implementations of DeviceFormatter
must add the new function or compilation will fail. Voilà!
Suppose now that our program not only needs to keep track of electronic devices but also accessories for those devices:
type Accessory = { color: string; size: number; };
Again, we want a type for an object that can provide string formatting functions for all the properties of Accessory
. We could implement an AccessoryFormatter
type, similar to how we implemented DeviceFormatter
, but we end up with mostly duplicate code:
type AccessoryFormatter = { [Key in keyof Accessory as `format${Capitalize<Key>}`]: (value: Accessory[Key]) => string; };
The only difference is that we’ve replaced references to the Device
type with Accessory
. Instead, we can create a generic type that takes Device
or Accessory
as a type argument and produces the desired mapped type. Traditionally, T
is used to represent the type argument.
type Formatter<T> = { [Key in keyof T as `format${Capitalize<Key & string>}`]: (value: T[Key]) => string; }
Note that we have to make one slight change to our property name transformation. Because T
could be any type, we don’t know for sure that Key
is a string
(for example, arrays have number
properties), so we take the intersection of the property name and string
to satisfy the constraint imposed by Capitalize
.
See the TypeScript documentation on generics for more detail on how they work.
Now, we can replace our bespoke implementations of DeviceFormatter
and AccessoryFormatter
to use the generic type instead:
type DeviceFormatter = Formatter<Device>; type AccessoryFormatter = Formatter<Accessory>;
Here is the full final code:
type Device = { manufacturer: string; price: number; releaseYear: number; }; type Accessory = { color: string; size: number; }; type Formatter<T> = { [Key in keyof T as `format${Capitalize<Key & string>}`]: (value: T[Key]) => string; }; type DeviceFormatter = Formatter<Device>; type AccessoryFormatter = Formatter<Accessory>; const deviceFormatter: DeviceFormatter = { formatManufacturer: (manufacturer) => manufacturer, formatPrice: (price) => `$${price.toFixed(2)}`, formatReleaseYear: (year) => year.toString(), }; const accessoryFormatter: AccessoryFormatter = { formatColor: (color) => color, formatSize: (size) => `${size} inches`, };
Try this code in the TypeScript playground.
Mapped types are not the only useful construct we can build within the type system. Using the same foundational concepts, we can also create custom utility types that allow other engineers to derive their own types and keep their programs DRY.
Mapped types are so fundamental that some mapped type definitions are adopted as standard TypeScript utility types — like the OptionalInterface
definition below:
interface INameAgeNationalityRequired { age: number; name: string; nationality: string; } type OptionalInterface<T> = { [K in keyof T]?: T[K]; } let allOptional: OptionalInterface<INameAgeNationalityRequired> = {}
This makes all props in T optional and adopted as a standard TypeScript type called Partial``<T>
:
type Partial<T> = { [P in keyof T]?: T[P]; };
TypeScript ships with a number of useful utility types out of the box.
Partial<Type>
returns a type of the same shape as Type
, but where all the keys are optional. An example where this utility type may be useful is when a user fills out a form where user input is optional and falls back to a set of default values. The Partial<T>
type would represent the user’s input, while the original type would represent the user’s input blended with the default values.
Similarly, Readonly<Type>
returns a type of the same shape as T, but where all the keys are read only:
type Readonly<T> = { readonly [P in keyof T]: T[P]; };
We can use Readonly<T>
as follows:
let readonlyValues: Readonly<INameAgeNationalityRequired> = { age: 1, name: “Emmanuel”, nationality: "Nigerian" }
Here, we have used the Readonly
mapped type with the INameAgeNationalityRequired
interface to create the readonlyValues
variable. Because the INameAgeNationalityRequired
interface requires name, age and nationality properties, we have provided values for these properties.
Now, if we try to assign values to any of these properties (age and name) as follows:
readonlyValues.age = 1;
TypeScript will throw the following error:
error TS2540: Cannot assign to 'age' because it is a read-only property.
Outside of the standard mapped types of Partial
and Readonly
, there are two other interesting mapped types:
Pick<Type, Keys>
As the name implies, the Pick
mapped type is used to construct a type that picks properties of another type:
type PickAgeName = Pick<INameAgeNationalityRequired, "age" | "name">;
Here, we construct a new type PickAgeName
using the Pick
mapped type, and provide two generic types. The first generic type is the INameAgeNationalityRequired
interface, and the second generic type is a string literal, which matches keys of the original type INameAgeNationalityRequired
. Essentially, the Pick
mapped type will select a set of properties from the original type to apply to the new type.
The PickAgeName
type defined above can be used as follows:
let pickAgeAndName: PickAgeName = { age: 22, name: "Paul" }
If we decide to add any other property to pickAgeAndName
variable, TypeScript will throw the following error:
Pick<Type, Keys>
returns a type derived from Type
that only contains the keys specified by Keys
. This utility type is useful for areas of a program that don’t require the full type, like related entities in an API response — perhaps the /user
API endpoint returns a user with all their data (name, username, attributes, etc.), whereas the /team
API endpoint returns a list of users with only partial user data (username only, for example).
Using generics, index access types, union types, and other tools as described in this article, we can easily add our own utility types for use alongside TypeScript’s default utility types.
TypeScript’s mapped types provide a powerful way to keep related types in sync automatically. They can also help prevent bugs by keeping types DRY and preventing the need to repetitively type (or copy and paste) similar property names.
Happy typing!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
4 Replies to "Mastering TypeScript mapped types"
This was incredibly helpful and written with such clarity I’m in awe.
Thanks, Rob! Happy to hear it!
Great article! Typescript’s documentation is concise but it’s always nice to have a second look of Typescript’s fundamentals
Thanks, Ignacio!