Introduction
This post provides an in-depth guide on how to derive mapped types in TypeScript.
A mapped type in TypeScript is a new type derived from a base type with the help of a custom type mapper utility.
Deriving new types with a custom type mapper is a common practice that ensures DRY (Dont' Repeat Yourself) code in a TypeScript codebase. There are a number of ways by which new types are derived in TypeScript and custom mapping is one such technique.
A TS type mapper builds on TypeScript's index signature syntax to transform and produce a new type from a union type.
In this post, we explore how to define and use custom-type mapper utilities with examples that derive new types from source types. We first understand underlying TypeScript concepts that entail deriving mapped types: the TypeScript index signature syntax, union of types, and the in
and keyof
operators. We elaborate and see how these concepts can be combined to define custom generic type mapper utilities that map passed-in type parameters into reusable type definitions. We also dig deeper and learn about ways of remapping and applying useful transformations to derived types with the help of the TypeScript as
operator, and native type utilities like Capitalize<>
and Exclude<>
.
Steps to be covered in this post:
- What are Mapped Types in TypeScript?
- How are Mapped Types Created?
- Building Blocks of a Custom TypeScript Type Mapper Utility
- TypeScript Mapped Types with Generic Type Mappers
What are Mapped Types in TypeScript?
A mapped type in TypeScript is a new type derived from a source type. Mapped types can be trivial as well as complex ones manipulated by a generic type mapper. In a generically mapped type, the keys of the new type are derived by mapping to the keys of the source type.
Below are examples of a generically mapped type and its associated source type:
// A generically mapped type:
type TThemeSetters = {
setDefaultTheme: () => void;
setOrientalTheme: () => void;
setVikingTheme: () => void;
setSpringTheme: () => void;
setSantaTheme: () => void;
};
// Source type from which the above mapped type was derived:
type IThemes = {
default: {};
oriental: {};
viking: {};
spring: {};
santa: {};
};
As we can see, each of the keys in the derived type have been mapped from their corresponding source type keys.
How are Mapped Types Created?
TypeScript mapped types are created via a custom type mapper utility. The mapper utility is defined in a way that it helps customize and transform the mapped type according to new type requirements.
What is a TS Custom Type Mapper Utility?
A custom-type mapper utility is usually a generic type definition that derives a new type from a source type. It takes in the source type as a parameter, performs necessary transformations, and then produces a new separate version with the desired mapped type.
During the transformation, the keys and values are transformed as needed. Common transformations made in a type mapper utility include changing key name patterns, the shape of their values, filtering keys, adding keys, making them readonly
, making properties optional, etc.
The custom generic type mapper for the above example looks like this:
type TThemeSetterMapper<Source> = {
[Property in keyof Source as `set${Capitalize<
string & Property
>}Theme`]: () => void;
};
How Do Custom TS Type Mappers Work?
As you can see in the above example, a custom type mapper leverages TypeScript's index signature syntax to build the derived type. It uses the keyof
type operator to create a union of keys from the source type, the in
narrowing operator to loop through the union keys, and assigns types to the values of each key.
Where necessary, it uses the as
type assertion clause to perform remapping, transformations, and conditional mapping.
Building Blocks of a Custom TypeScript Type Mapper Utility
Before we get into the mechanics of the above examples, in this section, we spend some time to get a fair understanding of the above-mentioned underlying building blocks that make a custom-type mapper utility.
TypeScript Custom Type Mapper Building Blocks: The Index Signature Syntax
TypeScript index signature syntax forms the most important component of a custom type mapper. An example looks like this:
type TThemeAction = {
[key: string]: {
start: string;
end: string;
};
};
In this most basic example, we are allowing an arbitrary string property to be set inside a theme action object. The value has to be an object with start
and end
properties, both of which must have string
values.
Using index signatures this way, however, is unconstrained, as we can add as many properties as we possibly could -- something that is not helpful for type specificity.
TS Custom Type Mapper Building Blocks: Union of Types and the in
Operator
TypeScript custom type mappers utilize a union of types to enforce type specificity by limiting properties to members of the union. The union members are then looped over with the in
operator and mapped to members of the new type:
type TThemeName = "default" | "oriental" | "viking" | "spring" | "santa";
type TThemeAction = {
[name in TThemeName]: {
start: string;
end: string;
};
};
In the example above, we have the TThemeName
type, which is a union of string
s that limits the mapped type's keys to the union members. So, the index signature with the TThemeName
union maps to the following TThemeAction
type:
type TThemeAction = {
default: {
start: string;
end: string;
};
oriental: {
start: string;
end: string;
};
viking: {
start: string;
end: string;
};
spring: {
start: string;
end: string;
};
santa: {
start: string;
end: string;
};
};
As you can see, mapping types with index signatures is proving already useful in deriving an object type with crowded keys. Thanks to TypeScript custom type mapping, we are able to easily produce an oversized shape which we'd otherwise be overwhelmed with.
TypeScript Custom Type Mapper: Union of Keys with keyof
Mapping types in TypeScript is more useful when we have a complex source type. One example would be theme definitions that typically involve a myriad of nested properties:
interface IThemes {
default: {
/*...nested theme stuff here */
};
oriental: {
/*...nested theme stuff here */
};
viking: {
/*...nested theme stuff here */
};
spring: {
/*...nested theme stuff here */
};
santa: {
/*...nested theme stuff here */
};
}
type TThemeName = keyof IThemes; // Equivalent to: "default" | "oriental" | "viking" | "spring" | "santa";
type TThemeAction = {
[name in TThemeName]: {
start: string;
end: string;
};
}; // Produces the same TThemeAction map as in the previous example with plain union type
In this case, we have a sophisticated IThemes
type that usually involves nested members, which pose a challenge to derive or transform types manually or using TypeScript's native utilities.
Notice the TThemeName
type this time. And how we are able to get the necessary union type from the source type keys (IThemes
) with the keyof
operator. Also notice how it produces the same TThemeAction
type as in the previous occasion.
TypeScript Mapped Types with Generic Type Mappers
Okay, so far we have been mapping the source type directly. Custom-type mappers are efficient when they are DRY and reusable with a generic definition. So, in this section, we explore examples of generic custom-type mappers.
What are TS Custom Generic Type Mappers?
Generic type mappers in TypeScript accept the source type as a parameter and any additional parameters to perform necessary transformations on the source type's keys and values.
How to Define a TS Custom Generic Type Mapper Utility
Below is an example of how to define a generic version of the above TThemeAction
type definition:
type TAction<Actors> = {
[Property in keyof Actors]: {
start: string;
end: string;
};
};
type TThemeAction = TAction<IThemes>; // Produces the same `TThemeAction` mapped type
Here, we have refactored the type mapper to a generic that takes the source type (Actor
) as a parameter and then uses it to make the transformations. This makes the type mapper reusable.
We can make the mapper more versatile by passing the value of the key as parameter as well:
type IThemes = {
default: {};
oriental: {};
viking: {};
spring: {};
santa: {};
};
type TAction<Actors, Value> = {
[Property in keyof Actors]: Value;
};
type TThemeAction = TAction<IThemes, { start: string; end: string }>; // Produces the same `TThemeAction` mapped type
This time around, we are passing both the source type and the shape of the value of each key as params. This way, our type mapper is more adjustable to diverse use cases with various target shapes.
TypeScript Type Mapper Utility vs TS Mapped Type: The Difference
It should be obvious by now that both the type mapper utility and the mapped type derived from it are TypeScript types. In other words, they are expressed as types.
One important difference is that TypeScript type mappers are type definitions, while mapped types are type assignments declared from those definitions.
TS Custom Type Mapper: Remapping with the as
Clause
With a TypeScript type mapper, we can apply different sorts of transformations to the source keys. We can change the pattern of key identifiers, filter, or add new keys. We use the TypeScript as
clause to modify the key sets and their identifiers.
For example, in a scenario where we need a type that describes the setter functions of all the theme items in the IThemes
object above, we can easily derive new identifiers that produce a theme setter type by transforming the key identifiers. As with the following mapper:
type IThemes = {
default: {};
oriental: {};
viking: {};
spring: {};
santa: {};
};
type TThemeSetterMapper<Source> = {
[Property in keyof Source as `set${Capitalize<
string & Property
>}Theme`]: (theme: {}) => void;
};
type TThemeSetters = TThemeSetterMapper<IThemes>;
// Produces the following type:
/*
type TThemeSetters = {
setDefaultTheme: (theme: {}) => void;
setOrientalTheme: (theme: {}) => void;
setVikingTheme: (theme: {}) => void;
setSpringTheme: (theme: {}) => void;
setSantaTheme: (theme: {}) => void;
};
*/
We can refactor this even further to make the setter mapper more generic:
type TSetterMapper<Source, SetterTarget> = {
[Property in keyof Source as `set${Capitalize<string & Property>}${Capitalize<
string & SetterTarget
>}`]: (theme: {}) => void;
};
type TThemeSetters = TSetterMapper<IThemes, "Theme">; // Produces the same mapped type of theme setters as above
Notice in both occasions, we are transforming the key identifiers with TypeScript's Capitalize<>
utility. Native TypeScript transformation utilities are instrumental in deriving custom-mapped types.
TypeScript Mapped Types: Key Filtering
While deriving a mapped type, we can apply key filtering with the Exclude<>
utility.
For example, we can remove keys if we need to. Below is an example that let's us derive a mapped type with keys of our choice removed:
type TFilterMapper<Source, Keys> = {
[Property in keyof Source as Exclude<Property, Keys>]: Source[Property];
};
type TFilteredThemes = TFilterMapper<IThemes, "santa" | "spring">;
Here, we are using the TypeScript Exclude<>
utility to filter the keys of our choice from the union of keys generated to Source
. Passing Keys
as a parameter to the mapper allows us to selectively pass the keys to be excluded in the derived type. The resulting type has santa
and spring
removed from its shape:
type TFilteredThemes = {
default: {};
oriental: {};
viking: {};
};
Notice again that, the TFilterMapper
is reusable. This means we can filter any source type to exclude any keys. For example, it can be used for filtering other source types:
type TNotificationOptions = {
desktop: boolean;
email: boolean;
mobile: boolean;
};
type TFilteredNotificationOptions = TFilterMapper<
TNotificationOptions,
"mobile"
>;
// Produces the following type:
/*
type TFilteredNotificationOptions = {
desktop: boolean;
email: boolean;
};
*/
Notice, in this case, we have directly mapped the value to its original type with Source[Property]
.
When to Use TS Custom Type Mappers?
TypeScript mapped types are needed to derive types from key application entity types, such as configuration objects, backend API data shapes.
The general intent is to provide type safety to mission-critical application entities and reduce runtime errors ahead of time. In terms of developer experience, mapping complex types to newer derivations with custom-type mappers provides convenience and development efficiency.
Custom TypeScript Mapped Types are used for:
- Deriving new types with transformed keys.
- Producing new types with filtered keys.
- Deriving mapped types with added keys.
- Mapping to new types with transformed values.
- Defining reusable generic utilities.
- Deriving safer types with
readonly
properties. - Deriving partial types with optional properties.
Summary
In this post, we explored in significant depth the use of TypeScript mapped types with custom type mappers. We first learned what mapped types and custom type mappers are. We then we examined the index signature syntax, union types, the in
and keyof
operators, and how they are used in a custom type mapper in TypeScript.
We then explored with examples of how to derive mapped types from complex IThemes
type with the help of custom defined generic type mapper utilities. Towards the end, we covered examples that demonstrate how to transform keys and their values with the TypeScript as
clause, Exclude<>
, and Capitalize<>
utilities.