How to better understand TS generics? Mapped Types
This is the third blog post in the series, if you have not read the first two go ahead:
In this article we will explore the powerful TS feature, Mapped Types.
For understanding Mapped Types, we will first need to understand:
Indexed Access Types
Index access types are types that are used to look up a specific type property in other types using square brackets []
.
Here we are accessing the property ‘age’
and receiving the type number.
We can also access the types using a union:
Remember that the access type can be either number
, string
or union
.
Index access types are very powerful and I'll show you some neat ways to use them.
- Index access type — Object to a union.
We can use index access types to create a union type from const
object.
How does it work?
1. On line 5 we use the as const
to make all fields of Prizes
read-only.
2. Then we get its type using typeof
.
3. We create a union
from the type we just created using keyof
.
4. We use index access type to create a union of its values.
5. profit!
Index access types can do even more!
2. Access nested types using another set of brackets.
3. Access arrays: types
Now that we understand what index access type can do, there is one more thing that we need to know before using them with Generics.
Index Signatures
Index signatures are used when we don’t know ahead of time the name of the type properties in object types, but we do know the type of values.
For example, using string as keys:
Here the keys (name of employees) can be any string and the value has to be a number.
We can also use number type as keys
numberArray
and stringNumberArray
are both of the same type.
Because when indexing with a number JS will actually convert the number into a string
.
So, indexing with the number zero is the same as indexing with the string '0'
.
Index signatures are a useful way to describe a dictionary, but they have one big constraint: they force all the “value” types to be the same.
x
has the type number
that is not assignable to string.
We can solve this problem using a union
as the value for the index signature.
We are ready to learn one of the greatest tools in TypeScript.
Mapped Types
With index signatures we defined for an arbitrary key its type value.
What if we want a type object with only specific keys?
This is where mapped types are very handy!
For understanding the basics of Mapped Types, we are going to use the following example.
We are building a function that is coloring a specific box side,
We have a Color
type: (yes, we support only three).
type Color = 'red' | 'blue' |'green'
We have Sides
type
type Sides = 'right' | 'top' | 'bottom' | 'left'
And we want to define a Box
type, which is a map between side and color.
We can try to solve this with index signatures:
With index signatures we can only use arbitrary keys, which means that box
variable is valid Box
type (even with ‘lef’ typo).
We want our box to have all Sides
as a key and have no other keys.
Using Mapped Types:
Having a mental model of looping helps understanding Mapped types.
- The
in
keyword in the mapped type indicates an “iteration process”. - To the right of
in
is the union we “iterate” over. In this case: Sides. - To the left of
in
is the item we are “iterating” with. In this case: Side.
And now using Box
type
We must have all Sides
as keys and we can’t add any key other than Sides
. If we do, it will cause a type error.
As this is an article about generics. Let’s see how Generics and mapped types work together.
Record using Mapped Types
The last example is a very specific way to create a map between specific keys to a value type.
type Box = {[Side in Sides] : Color}
We want to create a more generic and reusable type and for this we will use generics type parameters.
We will call our generic type OwnRecord
. The first type parameter will be KeyProps
,which will represent the union
we “iterate” over.
We will put a constraint on our generic parameter, KeyProps extends string
, which means we expect it to be a string
.
We are missing the second parameter, the generic value.
The generic value does not need to have any constraints as we want the user of this type to determine any Value
type they want.
That’s it, we have our very own generic record. Let’s build a Box
with it!
Box
is exactly the same type as before, OwnRecord
is generic and reusable, and similar to the built-in type Record
.
The only difference is K extends keyof any
.
type KeyOfAny = keyof any // string | number | symbol
This means the built-in Record type allows also to have number
, symbol
and string
as key.
You might think we would stop here, huh? No way!
We want to add index access types to the mix.
Mapped Types with index access types
It’s time to combine the knowledge we acquired to build even more powerful types.
We want to create our own custom addEventListner
that allows us to listen to specific event types.
We want to allow listening only to click
, mouseenter
and mouseleave
.
customEventListner
needs to have more constrained types, for the eventType
and for the listener
.
To achieve this, we will use the built-in type of WindowEventMap
and we will create a subset of it.
WindowEventMap
is a mapping between an event name and the event type.
E.g click: MouseEvent
.
Let’s use mapped types and index access types to achieve this.
First, we iterate over the union using the mapped type. Then, we use the key to receive the value from the object type using index access type.
We got CustomEventMap
but we want to create a more reusable type that will help us do it in an easier way in the future.
First, pull the union that we iterate out to a type parameter.
We introduced the type parameter Keys
for the union and constrained it to be keyof WindowEventMap
.
This means that only union of keys of WindowEventMap
are acceptable as the type parameter.
Now we can use any subset of keys from WindowEventMap
which is pretty generic.
Can we make it even more generic? Of course, we can!
We will parametrize WindowEventMap
.
We introduce a second type parameter named ObjType
.
This is the type from which we are creating a sub object type.
Pay attention that Keys
are constrained to be the keyof
ObjType
, therefore we are guaranteed to have in the result type only keys and value from ObjType
.
Now we can create any sub object type we want! We have just built the fantastically useful TypeScript built-in Pick
.
Which will generate the exact same type.
And now we are ready to have a typesafe customEventListner
We allow listening to subsets of events, and as a bonus, the listner
is type safe, which is a great success!
What if instead of picking keys from an object type, we want to omit keys from an object type?
Try to figure it out for yourself before you keep scrolling.
We can use PartObj
that we already built in order to solve this, the first part of it should probably stay identical.
We still need a type parameter for the union and the object type.
type OmitObj<ObjType,Keys extends keyof ObjType> =
Now we need to figure out which set of keys we need to iterate over.
We can’t simply iterate over keys, that will give us the same result.
We need to iterate on all other keys in the object type except the one in Keys.
Luckily we already know the tool for this job (from previous articles): Exclude
.
We are excluding the Keys
from all the keys of ObjType
(create a union without Keys
) and iterating over them, which results in a subset of the object type with omit of certain keys.
And clearly this is another TypeScript built-in Omit<T, K>
.
Sum up
In this article we explored many features of TypeScript, starting with index signature and index signature types.
We used that knowledge to learn about the powerful Mapped types.
We have used Mapped types to build our own version of the built-in types Pick
and Omit
.
Equipped with this knowledge I hope you can use Generics in a more versatile and useful way.
I have used the TypeScript docs, which has a lot more to offer.
And this great course in FM:
Keep on learning!