Mapping types is a convenient TypeScript feature that allows authors to keep their types DRY (” Don’t repeat yourself “). However, because they fall between programming and metaprogramming, they can be difficult to understand at first.
In this article, we’ll introduce some basic concepts for implementing mapping types and then walk through a high-level, real-world example.
Why use mapping types in TypeScript?
Using mapped types in your programs is especially useful when you need a type to derive from (and synchronize 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;
};
Copy the code
This example is problematic because there is an implicit relationship between AppConfig and AppPermissions. Each time a new configuration value is added to AppConfig, there must also be a corresponding Boolean value in AppPermissions.
It is better to let the type system manage this relationship than to rely on the discipline of a future program editor to update both types appropriately at the same time.
We’ll delve into the details of mapped type syntax later, but here’s a preview of the same example that uses 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
};
Copy the code
Basic concepts of mapping types
Mapping types build on each of these concepts and TypeScript features.
What is a mapping type?
In the context of computer science, the term “mapping” means transforming one thing into another, or, more commonly, transforming similar items into different lists of transformed items. The most familiar use of this idea is probably array.prototype.map (), which is used in everyday TypeScript and JavaScript programming.
[1, 2, 3].map(value => value.toString()); // Yields ["1", "2", "3"]
Copy the code
Here, we map each number in the array to its string representation. Therefore, mapped types in TypeScript mean that we convert one type to another by applying a transformation to each of its attributes.
Index access types in TypeScript
TypeScript authors can access the type of an attribute by looking it up by name.
type AppConfig = {
username: string;
layout: string;
};
type Username = AppConfig["username"];
Copy the code
In this case, the parse type of Username is string. For more information on index access types, see the official documentation.
The index sign
Index signatures are handy when the actual names of the attributes of a type are not known, but the data types they will reference are known.
type User = { name: string; preferences: { [key: string]: string; }}; const currentUser: User = { name: 'Foo Bar', preferences: { lang: 'en', }, }; const currentLang = currentUser.preferences.lang;Copy the code
In this example, the TypeScript compiler reports that currentLang is of type string instead of any. This capability, along with the keyof operator detailed below, is one of the core components that make mapping types possible. For more information on index signatures, see the official documentation for object types.
Use associative types in TypeScript
An alliance type is a combination of two or more types. It signals to the TypeScript compiler that the underlying value can be of any type in the union. This is a valid TypeScript program.
type StringOrNumberUnion = string | number; let value: StringOrNumberUnion = 'hello, world! '; value = 100;Copy the code
Here’s a more complex example that shows some of the advanced protections the compiler can provide for 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.
}
Copy the code
For more information about union types, see the documentation for everyday types.
usekeyof
Type operator
The keyof type operator returns the union of keys of the type passed to it. For example.
type AppConfig = {
username: string;
layout: string;
};
type AppConfigKey = keyof AppConfig;
Copy the code
AppConfigKey type is resolved as “username” | “layout”. Note that this also works with index signatures.
type User = { name: string; preferences: { [key: string]: string; }}; type UserPreferenceKey = keyof User["preferences"];Copy the code
UserPreferenceKey type parsing string | number (number, because through digital access JavaScript object attribute is a valid syntax). Read about the keyof type operator here.
Mapping types: A real-world example
Now that we’ve covered the basics of TypeScript’s mapping type capabilities, let’s take a look at a detailed real-world example. Suppose our program tracks electronic devices and their manufacturers and prices. We may have one such type for each device.
type Device = {
manufacturer: string;
price: number;
};
Copy the code
Now, we want to make sure that we have a way to display these devices to the user in a human-readable format, so we’ll add a new type for an object that formats each property of the Device with the appropriate formatting function.
type DeviceFormatter = {
[Key in keyof Device as `format${Capitalize<Key>}`]: (value: Device[Key]) => string;
};
Copy the code
Let’s take this code block apart and look at it piece by piece.
Key in keyof Device uses the keyof type operator to generate a union of all keys in the Device. Putting it in an indexed signature essentially iterates through all the Device properties and maps them to the DeviceFormatter properties.
Format ${Capitalize
} is the transformation part of the map, using Key remapping and template literal types to change the attribute name from X to formatX.
(value: Device[Key]) => string; We use the index access type Device[Key] to indicate that the value argument to the formatting function is the type of the property we are formatting. So, the formatManufacturer takes a String (manufacturer) and the formatPrice takes a number (price).
Here’s what the DeviceFormatter type looks like.
type DeviceFormatter = {
formatManufacturer: (value: string) => string;
formatPrice: (value: number) => string;
};
Copy the code
Now, let’s assume we add a third property, releaseYear, to our Device type.
type Device = {
manufacturer: string;
price: number;
releaseYear: number;
}
Copy the code
Thanks to the power of the mapping type, the DeviceFormatter type is automatically extended like this without us doing any extra work.
type DeviceFormatter = {
formatManufacturer: (value: string) => string;
formatPrice: (value: number) => string;
formatReleaseYear: (value: number) => string;
};
Copy the code
Any implementation of DeviceFormatter must add new functions or the compilation will fail. See?
Bonus: a reusable formatting type with generics
Suppose our program now needs to track not only electronic devices, but also accessories for those devices.
type Accessory = {
color: string;
size: number;
};
Copy the code
Also, we want an object type that provides string formatting for all the properties of the Accessory. We could implement an AccessoryFormatter type, similar to the way we implemented DeviceFormatter, but we would end up with most of the duplicate code.
type AccessoryFormatter = {
[Key in keyof Accessory as `format${Capitalize<Key>}`]: (value: Accessory[Key]) => string;
};
Copy the code
The only difference is that we replace a reference to the Device type with an Accessory. Instead, we create a generic type that takes Device or Accessory as a type parameter and produces the desired mapping type. Traditionally, T has been used to represent type parameters.
type Formatter<T> = {
[Key in keyof T as `format${Capitalize<Key & string>}`]: (value: T[Key]) => string;
}
Copy the code
Note that we have to make a slight change to our attribute name conversion. Because T can be of any type, we can’t be sure that Key is a string (for example, arrays have the number attribute), so we take the intersection of the attribute name and string to satisfy the Capitalize constraint.
For more details on generics, see how generics work in TypeScript documentation. Now we can replace our custom implementations of DeviceFormatter and AccessoryFormatter with generics instead.
type DeviceFormatter = Formatter<Device>;
type AccessoryFormatter = Formatter<Accessory>;
Copy the code
Here is the complete 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`,
};
Copy the code
Try this code on the TypeScript playground at typescriptlang.org.
conclusion
Mapping types provide a powerful way to automatically keep related types in sync. They can also help prevent errors by keeping types dry and avoiding re-typing (or copying and pasting) similar attribute names.