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.

usekeyofType 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.