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 ofT
[K in keyof T]
loop through all the properties ofT
- and finally
[K in keyof T]: string
, loop through all the properties ofT
and assignstring
as theirtype
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 HoodThe Required<T> is defined as follows:
/**
* Make all properties in T required
*/
type Required<T> = {
[P in keyof T]-?: T[P];
};
Let us break it apart:
- This is a Mapped Type
keyof T
gets all properties ofT
[P in keyof T]
loop through all the properties ofT
- 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 propertyP
is as part of typeT
. This way the original type ofP
is used in the mapped type. - Finally
[P in keyof T]-?: T[P];
can be read as loop through all the properties ofT
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>
.
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;
Let us break it apart:
- This is a conditional type
Exclude<T, U>
defines a generic with two variables:T
andU
T extends U
is the conditional check of the conditional type.- If
T extends U
thennever
is returned - If
T extends U
is false, thenT
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.
No comments:
Post a Comment