Friday, June 11, 2021

Understanding the Magic behind Utility Types in TypeScript

This post will be the conlcuding post in the Introduction to Advanced Types in TypeScript series. It looks at some of the utility Types in TypeScript, explain how it works while pointing out the features within the TypeScript type system that makes it possible.

Why TypeScript?

TypeScript comes with a powerful and expressive type system. Its expressiveness makes it a joy to work with while its powerful features makes it possible to build, and scale large codebases by providing type safety.

One of the ways TypeScript brings about type safety is by providing developers the tool to manipulate types in ways that encode constraints within the type system. This then ensures that code that could lead to runtime exception do not compile, making it possible to catch errors during development time and not in production. One of such tools TypeScript provides for this are Utility Types.

Utility Types are a set of Generic Types that come natively within TypeScript that makes it possible to transform one type into another. This sort of type transformation is useful because it makes it possible to take exiting types, and apply modifications to it which would ensure the enforcement of certain constraints.

In this post, we would look at 2 of such Utility type and how they could be used to provide more type safety. After that, we would take a step back to understand some of the other TypeScript features that come together to make Utility Types possible. Armed with this knowledge, we will then demystify Utility types by taking a peek under the hood of the 2 Utility types in focus to see how they are implemented.

The two utility types that will be exmaned are Required<T> and  Exclude<T>. This post assumes basic knowledge of TypeScript and what Generic Types are. Other features of TypeScript type systems would be explained.

Required<T> and  Exclude<T>

We first look at how to make use Required<T> and  Exclude<T>. Starting with Required<T>.

Using Required<T>

Required<T> is an utility type that creates a new type based on an existing one by marking all the properties in the new type required.

To illustrates let’s imagine we have a Post type. A post can also have a score value which is calculated dynamically or might not even be calculated yet, hence the score property on the Post type should be optional. The definition of such a post type could look like this:

type Post = {
 title: string
 publishedDate: number
 score?: number
}

When the score on a post has been calculated we want this to be obvious and explicit. Having score as optional does not give use explicitness. One way to go about this, is to create another type with score not optional. For example:

type ScoredPost = {
 title: string
 publishedDate: number
 score: number
}

And we can then have a function that scores a post by taking a value of Post and converting it to a value of ScoredPost

function scorePost(post: Post): ScoredPost {
   return {
      title: post.title,
      publishedDate: post.publishedDate,
      score: post.publishedDate * Math.random()
     }
}

But having both Post and ScoredPost is a bit verbose as it is clearly obvious that ScoredPost is just a Post with the score property now required.

Having Post and ScoredPost is not only verbose it also adds to maintenance burden as any update to either of the types need to be manually reflected on the other.

Since ScoredPost is clearly a transformation of Post with score now required, we can easily apply the Required<T> utility type here. To do that, the scorePost function would be updated as follows:

function scorePost(post: Post): Required<Post> {
    return {
      title: post.title,
      publishedDate: post.publishedDate,
      score: post.publishedDate * Math.random()
     }
}

This shows the power of a utility type such as Required<T>: easily create a new type from an existing type while enforcing the required property constraint.

Using Exclude<T, U>

Exclude<T, U> is also another utility type that creates a new type based on an existing one. It does this by excluding certain properties from one type to create a new type.

To illustrates let’s imagine we have a AdminRights type and a UserRights type. The UserRights could either be read or write, while AdminRights is all the permissions presents in UserRights with the execute permission added. These two types could be defined as follows:

type  AdminRights = "read" | "write" | "execute"
type  UserRights = "read" | "write"

But having both AdminRights and UserRights can be seen as being verbose since UserRights is AdminRights without the execute option.

It also does not make it explicit that UserRights is all the AdminRights with the execute excluded. To make this clearer we can use the Exclude<T, U> utility type.

The definition would then look as follows:

type AdminRights = "read" | "write" | "execute" 
type UserRights = Exclude<AdminRights, "execute">

Here, we are creating a new type UserRights, from AdminRights by excluding the "execute" property. Making it explicit how UserRights is related to AdminRights.

Now we have seen two examples of Utility types in TypeScript. We would now take a quick look at some of the features of TypeScript that makes these utility types possible.

Building Blocks of Utility Types


KeyOf

The KeyOf type operator is an operator that can be used to get all of the properties of a type as a union type. For example, given the type Post:

type Post = {
 title: string
 publishedDate: number
 score: number
}

All the properties of Post, that is title, publishedDate and score can be retrieved as union type using the keyOf operator:

type Props = keyof Post
// Props => "title" | "publishedDate" | "score"
Check Introduction to Index Types in TypeScript for a more detailed introduction to the keyof operator.
Conditional Types

Conditional types can be thought of as a mechanism by which we can perform ternary operations on types. For conditional types, the operation that determines which type is returned is an assignability check, unlike a boolean check as is the case with normal ternary operation that works on values. A conditional types look as follows:


type  Post = {
   title: string
   publishedDate: number
   score: number
}
// the conditional type
type  RatedPost<T> = "score"  extends  keyof  T ? T : never

In the above code snippet, RatedPost\<T> is defined as a conditional type, which makes use of generics. It returns the type T as long as it has the score property, if not it returns never. never is a type that has no value ever, hence if we have a compile error if the conditional type ever evaluates in such a way that the never is returned.

See Conditional Types in Typescript for a more indepth exploration of Conditional Types. See any, unknown and never types in Typescript for a more detailed introduction of the never type

To see this in use, we can define a function: resetScore that only takes a post that already has a score. Such definition would look like this:

function resetScore(post: RatedPost<Post>) {
  // do stuff
}

This can be called as follows:

resetScore({
 title: "hello post", 
 publishedDate: 1621034427002, 
 score: 2
})

but passing an object that has no score property would lead to a compile error:

resetScore({
  title: "hello post", 
  publishedDate: 1621034427002
})
Argument of type ‘{ title: string; publishedDate: number; }’ is not assignable to parameter of type ‘Post’. Property ‘score’ is missing in type ‘{ title: string; publishedDate: number; }’ but required in type ‘Post’

Mapped Types

A mapped type is a type that is created by using the keyof operator to access the properties of another type and modifying the extracted properties in some way to create a different type. For example, a Mapped type that takes a Post type and convert all its properties to string would be defined as follows:

// initial type
type  Post = {
  title: string
  publishedDate: number
  score: number
}

// mapped type
type  StringPost<T> = {
   [K  in  keyof  T]: string
}

// usage of mapped type
let stringPost: StringPost<Post> = {
   title: "How to do the wiggle",
   publishedDate: "15/05/2021",
   score: "2"
}

Taking a closer look at the mapped type definition:

type  StringPost<T> = {
   [K  in  keyof  T]: string
}

We can break down the code listed above as :

  • keyof T gets all properties of T
  • [K in keyof T] loop through all the properties of T
  • and finally [K in keyof T]: string, loop through all the properties of T and assign string as their type
See Mapped Types in Typescript for a more indepth exploration of Mapped Types.

Now that we are armed with the knowledge of these TypeScript features, let's now see how they are applied to create Utility types.

Demystifying Utility Types

Utility types are created from the application of some or all of the TypeScript features highlighted in previous sections. We will now take a look at the source code of the two utility types described above: Required<T> and Exclude<T, U> and see how they are defined.

The source code can be found in es5.d.ts

Required<T> Under The Hood

The Required<T> is defined as follows:

/**
* Make all properties in T required
*/
type Required<T> = {
    [P in keyof T]-?: T[P];
};

source

Let us break it apart:

  • This is a Mapped Type
  • keyof T gets all properties of T
  • [P in keyof T] loop through all the properties of T
  • the -? in [P in keyof T]-? means remove the character ? from the definition of the properties. This is how the optionality of the properties are removed and made required
  • The T[P] in the type definition means, take as type whatever property P is as part of type T. This way the original type of P is used in the mapped type.
  • Finally [P in keyof T]-?: T[P]; can be read as loop through all the properties of T remove the optional modifier ?, and keep original type.

That is it. Note that in the case where modifiers are to be added, the - can be dropped which defaults to adding, or +, can be used.

For example, instead of making all properties required, if we want to make them option, we would have the definition as [P in keyof T]+?: T[P]; or [P in keyof T]?: T[P];. In fact there is utility type for this and it is called Partial<T>.

Exclude<T, U> Under The Hood

The Exclude<T, U> is defined as follows:

/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T;

source

Let us break it apart:

  • This is a conditional type
  • Exclude<T, U> defines a generic with two variables: T and U
  • T extends U is the conditional check of the conditional type.
  • If T extends U then never is returned
  • If T extends U is false, then T is returned.

The T extends U is checking if T is a subtype of U. That is, if all properties of U can be found in T. If this not the case, then T is returned which would then be all the properties ofU with the properties found in T excluded.

This is how the exclusion in Exclude<T, U> is implemented.

As can be seen, even though utility types are powerful, there is nothing mysterious about them and they are understandable. They are created by using other features of TypeScript.

TypeScript comes with more utility types defined which can be found here, and hopefully this post now helps in appreciating what they are and how they are implemented to do what they do.



I am writing a book: TypeScript Beyond The Basics. Sign up here to be notified when it is ready.

No comments: