Going Further With TypeScript - Part 1: Mapped Types

An introduction to mapped types
I’ll start start with an introduction of one of TypeScript’s powerful features: mapped types before we discuss how to create “prefix and suffix” utility types.
When you don’t want to repeat yourself, sometimes a type needs to be based on another type.
Imagine you have the following type:
type User = { name: string;}
Pretty simple so far. Now imagine a hypothetical state management implementation.
The next step is often creating a new type or interface to model specific state related operations - namely mutations, getters, and actions. Generics are a great way to achieve concepts like Result<T>
where T
is the type being operated on. But that still requires the creation of types for each operation. Of course, that’s assuming you aren’t using stringly typed systems.
The natural next step would be to create types such as:
type User = { name: string;}
type GetUser = { getName: () => string; // Notice how this is now a function.}
What if there was a way, with the power of TypeScript, to create these types automatically, with type safety? This could aid in the construction of the hypothetical state management store.
It is possible, and this this is where mapped types come in.
A mapped type can be summised by the formula type B = type A + transformation
.
// Example provided by the TypeScript documentationtype Getters<Type> = { [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]};
Step by step
That’s certainly, at first glance, some fairly heavy syntax. Let’s break it down to individual steps.
Creating an identical type
We start by creating almost the simplest mapped type in which the exact same properties are mapped to the new type.
type User = { name: string;}
type Getters<Type> = { [Property in keyof Type]: Type[Property]};
type NewUser = Getters<User>;
As can be seen in the playground link the NewUser
has identical properties. It could possibly be a so-called “identity type” akin to an ”identity function” in the world of functional programming and mathematics.
So what have we demonstrated so far? Well, we have mapped over a type to create an identical type. It stands to reason that we should be able to transform the properties and values of the new type.
Transforming the new type
Let’s create a new mapped type where the value of each property is a function instead of the existing primitive string value. Note how the function return type can also be specified - by indexing into the generic type.
type User = { name: string;}
type Getters<Type> = { [Property in keyof Type]: () => Type[Property]};
type NewUser = Getters<User>;
Types = Flexibility
Remember that as we are creating a new type we can define it anyway we like. The following code is only a demonstration of this ability.
type User = { name: string;}
type Getters<Type> = { [Property in keyof Type]: () => ({ foo: 'bar', baz: 'qux' })};
type NewUser = Getters<User>;
Changing the property names
We’ve covered creating a new value for a property. But what about the property name itself? Looking at the original full example from earlier, we see that it is possible.
type User = { name: string;};
type Getters<Type> = { [Property in keyof Type as `get${string & Property}`]: () => Type[Property]};
type UserGetters = Getters<User>; // Notice how the type has "getname".
That gets us a half-way solution - getname
. This is obviously not ideal as it’s not following conventional naming conventions. TypeScript allows us to use string interpolation to create the new key name. We need a way of changing the case of characters in the string. Remember, this is happening at compile time though - not at runtime.
Intrinsic String Manipulation Types
TypeScript can also help us with changing the character casing too with Template Literal Types or more specifically Intrinsic String Manipulation Types.
type Getters<Type> = { [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]};
type User = { name: string;};
type UserGetters = Getters<User>;
We’ve now come full circle and created the Getters<Type>
from the documentation and explained each step in the process. But can we do even better and make this reusable?
🔥 Yes! 🔥
Abstraction
First we need to define what functionality we need to extract from the example we have being building. Currently, we have a hardcoded get
being prefixed to every key in the given type. Additionally, we appropriately change the case of characters to follow normal naming conventions of the language. That seems like an ideal candidate to extract into it’s own module. There will be times when we wish to suffix every key as well. That’s another candidate to be extracted into the new module.
type Prefix<Type, Prefix extends string> = { [Property in keyof Type as `${Prefix}${Capitalize<string & Property>}`]: Type[Property]};
type Suffix<Type, Suffix extends string> = { [Property in keyof Type as `${Lowercase<string & Property>}${Capitalize<Suffix>}`]: Type[Property]};
Now that we have created the new module we have abstracted away the implementation details of how a mapped type can prefix/suffix values with appropriate capitalisation we can refactor the Getter
example.
type User = { name: string;};
type GetterNames<Type> = Prefix<Type, 'get'>;
type GetterFunctions<Type> = { [Property in keyof Type]: () => Type[Property]};
type Getters<Type> = GetterFunctions<GetterNames<Type>>;
type UserGetters = Getters<User>; // This is what we've been getting to ✨
Now that is fascinating!
A “type transformation pipeline”?
type Getters<Type> = GetterFunctions<GetterNames<Type>>;
Notice how we created separate GetterNames
and GetterFunctions
types and then effectively applied one mapped type onto another mapped type. This could keep going! This time an example with one of the existing utility types:
type Getters<Type> = ReadOnly<GetterFunctions<GetterNames<Type>>>;
How I felt after discovering mapped types and then realising the ability to further compose mapped types into a pseudo-pipeline where each stage transforms the type (or rather, creates a new type) further:
Conclusion
We have now successfully taken the example from the documentation and taken the following actions.
- Broken down the
Getters
example into discreet steps - Explained the inner workings of mapped types and how to re-map keys and values
- Abstracted the prefixing of keys and introduced two reusable generic and mapped types;
Prefix
andSuffix
- Refactored the existing code to use the new types leading to cleaner and easier to read code all while utilising the massive capabilities of TypeScript’s type system
These points make me really excited and I’m intrigued to see where I can take it next.