Building Utility Types in TypeScript
In the talk for the Capitole frontend comunity, we explored the powerful concept of Typescript’s type manipulation.These mechanisms eliminate the need for repetitive rewriting of types and instead allows us to generate new types by using existing ones and applying transformations through generic types. By harnessing these capabilities, we can significantly enhance code reuse and maintainability, as well as streamline the development process.
Typescript’s type manipulation enables us to view its type system as a programming language in its own right. This means that we can reason with types in a similar manner as we do with programming data. Just as we can obtain a result or output from an algorithm based on certain information, we can also derive a resulting type from a given type and algorithm.
In this article, we will leverage the features of Typescript to build utility types such as Pick, Omit, and Exclude. Although these types are already built-in within Typescript, we will use them as examples to explore the possibilities offered by Typescript’s type system.
Pick
The `Pick` utility type allows us to select specific keys from an object type and create a new object that contains only those selected keys. By providing an object type and a union of keys, `Pick` enables us to obtain a new object with only the desired keys.
type Todo = {
title: string;
description: string;
completed: boolean;}
type TodoPreview = MyPick<Todo, “title” | “completed”>;
const todo: TodoPreview = {
title: “Clean room”,
completed: false,};
In this scenario, we have a type called `Todo` that represents an element in a todo list. We want to generate another type, `TodoPreview`, which serves as a preview of the task. However, we want to avoid manually writing the `TodoPreview` type. Instead, we aim to *derive* this type generation based on the `Todo` type.
To build our new utility type `MyPick`, we need to explore some concepts that enable its implementation. Some of these key concepts include:
- Generics
- Mapped types
- Indexed access types
Generics
Generics can be understood through the following mental model: they provide us with the ability to generate reusable types in Typescript. This means that generics allow us to create types that can be used with a wide range of other types. In analogy with other mechanisms in traditional programming languages, generics can be compared to a function that takes an input and produces an output.
type HttpResponse<T> = {
statusCode: number;
path: string;
method: string;
data: T;}
In this case, we define a generic type called `HttpResponse`. The reason it is considered generic is because, in addition to having common properties shared by any HTTP response, it has the flexibility to represent various types of responses based on the corresponding request. This is achieved through the use of the generic parameter `T`. If we see it as a function, `HttpResponse` can be seen as a function that takes `T` as a parameter and returns an object representing an HTTP response.
Mapped types
Mapped types provide us with the ability to transform a union type into an object. On the other hand, a union type allows us to express that a value can exclusively contain either value A or value B.
type TrafficLight = ‘green’ | ‘yellow’ | ‘red’
Here, we define the `TrafficLight` type to indicate that any value assigned to it can only have one of three possible values: `green`, `yellow`, or `red`
Now we can transform this union type into an object type using mapped types
type TrafficLightSettings = {
[Light in TrafficLight]: {
kind: Light;
duration: number;
display: string}
}
This way, we use the `TrafficLight` type as an input for creating `TrafficLightSettings`. This is achieved by iterating through each element in the `TrafficLight` union type using the `in` keyword. During each iteration, the current element is assigned to the variable `Light`. We can then utilize `Light` on the right side of the expression to construct the resulting type. In this specific case, the resulting type is an object with properties such as `kind`, `duration`, and `display`.
Mapped types are especially useful when combined with generics, as they allow transformations across diverse set of types.
type AllNumbers = {
[Key in keyof T]: number
}
In this scenario, we utilize a generic `T` and transform all its properties to have the `number` type. The union type is obtained by using `keyof`, which generates an union type containing the names of all the properties of an object type.
Indexed access types
Both in vanilla JavaScript and TypeScript, we can access the value of a property using either dot notation or index notation. Additionally, in TypeScript, we can use the same index notation to retrieve the type of a property within an object type.
// Javascript
const person = {name: “Alejandro”,
age: 24}
console.log(person.name)
console.log(person[‘name’])// Typescript
type Person = {name: string;
age: number;}
type PersonName = Person[‘name’]
And with this we can now to build `MyPick`
type MyPick<T, Keys extends keyof T> = {
[Key in Keys]: T[Key] }
In our generic `MyPick`, we have two types: `T`, which represents the object from which we want to extract properties, and `Keys`, which specifies the properties we desire to obtain from `T`. The `Keys` type is constrained to only accept literal types that are *assignable* to the keys of `T`. This constraint is achieved using `extends` in combination with `keyof`. By doing so, we prevent getting keys that do not exist in `T`.
Omit and Exclude
`Omit` is a utility that, unlike `Pick`, enables us to remove a key from an object.
type Todo = {
title: string;
description: string;
completed: boolean;}
type TodoContent = MyOmit<Todo, ‘completed’>
In this case, `TodoContent` will contain the same information as `Todo`, but without the `completed `property.
At the same time `Exclude` removes elements from an union type
type TrafficLight = ‘green’ | ‘yellow’ | ‘red’
type CanMove = MyExclude<TrafficLight, ‘red’> // ‘green’ | ‘yellow’
Conditional types
To construct the exercise described above, we will need conditional types. Conditional types enable us to return one type or another based on a specific condition or if one type is *assignable* to another.
type GeneralForm = A extends B ? true : false
In this case, we determine if a type A is assignable to B, returning true if it is, and false otherwise. When combined with generics, this allows us to evaluate type conditions in a more complex way.
type GetMessageType = T extends {message: unknown} ? T[‘message’] : never;
We verify whether `T` contains or is assignable to an object type that includes a `message` property. This concept is similar to `pattern matching` found in other programming languages, where decisions or values are determined based on the shape of a given value.
Exercise solution
First, we will build `Exclude` since this type will enable us to build `Omit`.
type MyExclude<A, B> = A extends B ? never : A;
In this scenario, we use conditional types to check if every element within the union type A is *assignable* to B. If it is not *assignable*, we return `never`, which represents an empty set in TypeScript. If it is assignable implies that we should retain the element and return it.
Now with this we can build `Omit`
type MyOmit<T, OmittedKeys extends keyof T> = {[Key in MyExclude]: T[Key] }
As we saw earlier, we used mapped types to convert a union type into an object. By removing the undesired keys from the union type, we can get a resulting object that excludes those keys. This is how `MyOmit` uses `MyExclude` to remove the specified properties.
I hope you like this article and as a resource I higly recommend typescript handbook.
Alejandro Garcia Serna -Software Engineer at Capitole