Books

Sunday, April 25, 2021

Mapped Types in Typescript

This post will explore Mapped Types, another advanced feature of the Typescript type system. It is part of the Introduction to Advanced Types in TypeScript series.

A useful mental model to have when approaching some of the advanced type system features of Typescript is to view them as a mechanism for constructing other types from existing types. This view is spot on when it comes to mapped types as they are a mechanism that Typescript provides by constructing new types by mapping existing types into new ones.

This post would show how this looks like. It would be as beginner-friendly as possible, but having some knowledge of GenericsUnion Types and Literal types in Typescript would be a plus.

To kickstart we take a quick again, at the keyof Operator, as it is essential to the workings of Mapped Types.

keyof Operator


The key idea of Mapped Types is to create a new type by mapping (read modifying) the properties of existing types. And how do we get access to these properties? We use the keyof operator. As explained in Introduction to Index Types in Typescript, The keyof type operator is an operator that allows the retrieval of all the indexes (read properties) of a type as a union type. The individual components of the retrieved union types are the literal types that represent the properties of that type.

This can be illustrated as follows. Given the following type definition:

type Employee = {
name: string
employedOn: Date
age: string
role: {
  title: string
  salary: number
  isManagement: boolean
 }
}

The properties can be extracted as a union type as follows:

type Properties = keyof Employee

We can demonstrate that Properties is indeed a union type that consists of literal types based off the properties of Employee:

var value: Properties = "name" // compiles
var value: Properties = "employedOn" // compiles
var value: Properties = "age" // compiles
var value: Properties = "role" // compiles
var value: Properties = "gender" // does not compiles

All the variable definition compiles except the one where "gender" is to be assigned to a variable of Properties. And the reason why this does not compile is due to the fact that "gender" is not part of the properties of the Employee type.

So in summary, the keyof operator allows us to get access to the properties of a type as a union type.

A Mapped Type


A mapped type is then a type that is created by accessing the properties of another type, using the keyof operator, and modifying the extracted properties in some sort of way to create another type.  As an example, given a type definition as follows:

type Person = {
    age: string | number
    height: string | number
}

This type can be turned into another type where the properties are mapped from type string | number to string. To achieve that, a mapped type can be defined as such:

type ToStringProps<T> = {
    [P in keyof T]: string
}

Which can then be used on the Person type as follows:

type StringPerson = ToStringProps<Person>

var person: StringPerson = {
    age: "10",
    height: "6"
}

The StringPerson type thus created can only have properties of type string.

The Mapped type above is ToStringProps<T> and in this case,  it is used to modify the return type. But it is not only the return type of properties that can be modified using Mapped Types, property names and property identifiers (mutability and optionality identifier) can also be modified. The next example shows how these looks.

Modifying Identifiers using Mapped Types


Two identifiers can be modified using Mapped types. These are mutability, which is implemented via the readonly keyword, and optionality which is implemented via the ? symbol. The modification that can be achieved via mapped types involves adding or removing these identifiers. To add the identifier the Mapped type definition makes use of the + symbol, while to remove the target identifier, the - symbol is used.

To illustrate, a Mapped type that takes a type and produced an immutable version of it would look like this:

type Immutable<T> = {
    +readonly [P in keyof T]: T[P]
}

While a mapped type that takes a type and produced a mutable version would look like this:

type Mmutable<T> = {
    -readonly [P in keyof T]: T[P]
}

Note, when no symbol is used, then addition is assumed. Hence the Immutable mapped type can also be defined like this:
type Immutable<T> = {
    readonly [P in keyof T]: T[P]
}
The above examples show how the readonly which is responsible for mutability can be mapped. On the other hand, to illustrate how a mapped type can be used to produce another type with all of its properties turned optional, we have:

type Optional<T> = {
    [P in keyof T]+? : T[P]
}

While a mapped type that takes a type and produced another type where all its properties would have to be specified will look like this:

type NonOptional<T> = {
    [P in keyof T]-? : T[P]
}

Again, note, when no symbol is used, then addition is assumed. Hence the Optional mapped type can also be defined like this:

type Optional<T> = {
   [P in keyof T]? : T[P]
}

Modifying Property Name using Mapped Types


Mapped type can also be used to produce a new type by modifying the property names of an existing type. This is achieved by using the as keyword, in combination with Template literals. For example, a mapped type that updates the given type by prepending "_" to the property names will look like this:

type UnderlineProp<T> = {
    [P in keyof T as `_${string & P}`]: T[P]
}

which can then be used as follows:

type Person = {
    name: string
    age: number
}

type _Person = UnderlineProp<Person>

var _person: _Person = {
    _name: "John",
    _age: 20
}

 In the above example _Person is a type produced from Person type, using the mapped type definition of UnderlineProp

Mapped types are quite powerful and allow for sophisticated manipulation of types in Typescript, especially when used with other features of the type system. Above, it is used together with Template literals. It is also possible to make use mapped types together with Conditional types in determining the type of property to be updated.

This post is part of a series. See Introduction to Advance Types in TypeScript for more posts in the series

No comments:

Post a Comment