- Published on
- -12 min read
Intermediate Typescript: Generics and Mapped Types
Table of Contents
In the last post, I covered Literal and Union types. Those types are great and can get you a long way when writing your apps. When your codebase starts to grow you may find your middleware/helper functions still have too general of types which leads to more type casting than you would like. This is where generics and mapped types come in.
Generics
If your first interaction with generics was with java in school they may have put a bad taste in your mouth. Generics can be very simple and can help a lot with not repeating yourself. The simplest way to think of them is sort of like a function that takes types as parameters and yields back a new type. To drive that point home lets look at a few simple examples.
ts
typeAddString <T > =T | string;typeNumOrString =AddString <number>; // yields number | stringtypeStringOrString =AddString <string>; // yields just stringinterfaceBox <T > {value :T ;}conststringBox :Box <string> = {value : 'aString' };constarrayNumBox :Box <number[]> = {value : [1, 2, 3] };constliteralBox :Box <'aLiteral'> = {value : 'aLiteral' };
Generics act as a template, you define a type using a type parameter (in these cases T
, but it can be any identifier), when the generic is used it would fill in all instances of T
with the passed in type.
The most likely instance you would run into generics is with functions. Generics in functions allows types to flow through it when the function does not really care about any specific type.
ts
typeNullable <T > =T | null;functiongetWithDefault <T >(possibleValue :Nullable <T >,defaultVal :T ):T {if (possibleValue ) {// possibleValue is now TreturnpossibleValue ;}// returning a T when possibleValue is nullreturndefaultVal ;}constnullableNum :Nullable <number> = 10;constnum =getWithDefault (nullableNum , 2); // num is now just a number type
In this example, we use the same type T
3 times, as a Nullable<T>
, a default value, and the functions return type. Notice we do not need to say getWithDefault<number>(nullableNum,2)
, typescript infers that T should be set to number based on usage.
Generics to make types "flow"
Generics can help address a number of potential issues of types not "flowing" the way you want. A great example of this is a function modifying members of a union type. Recall the ApiEvent
type from the last post
ts
interfaceLoginEvent {type : 'login';user : string;wasSuccessful : boolean;}interfacePostCreatedEvent {type : 'postCreated';name : string;body : string;createdAt :Date ;}typeApiEvent =LoginEvent |PostCreatedEvent ;
Now let's say you are making a function that will take an event, and add a new field logged: boolean
to show the event was logged out. Your first attempt might look something like this.
ts
functionaddLogged (event :ApiEvent ):ApiEvent & {logged : boolean } {return { ...event ,logged : true };}
This makes sense initially however, when you go to use this function you notice an issue.
ts
constloginEvent :LoginEvent = {type : 'login',user : 'john',wasSuccessful : true };constupdated =addLogged (loginEvent );Property 'user' does not exist on type 'ApiEvent & { logged: boolean; }'. Property 'user' does not exist on type 'PostCreatedEvent & { logged: boolean; }'.2339Property 'user' does not exist on type 'ApiEvent & { logged: boolean; }'. Property 'user' does not exist on type 'PostCreatedEvent & { logged: boolean; }'.console .log (updated .); user
Weird, it's obvious to you that all this function does is add on a field, why is typescript complaining that user
does not exist on PostCreatedEvent
? The issue is the function definition. Based on the types we pass in ApiEvent
and get back ApiEvent
with some extra stuff. To the type system we could just always return a PostCreatedEvent
with the logged field.
Generics help us tell typescript what we put in, is what we are going get out. Let's re-write this function like so.
ts
functionaddLogged <T extendsApiEvent >(event :T ):T & {logged : boolean } {return { ...event ,logged : true };}constloginEvent :LoginEvent = {type : 'login',user : 'john',wasSuccessful : true };constupdated =addLogged (loginEvent );console .log (updated .user ); // no error
Notice we did not change any logic of the function, just the type definition. We can still only pass in ApiEvent
s but when we pass in a specific LoginEvent
the type system knows we are only going to get back a LoginEvent
. The extends
keyword for generics is very powerful to restrict the possible allowed values for a function while still reasoning about specific types.
This does not lose generality if you had a list of ApiEvents
you could still map over them with this function.
Generics in type definitions
Sometimes you may have a few wrapper types that hold the same types. This example is a little contrived, but I've run into this a few times before.
ts
typeValidValue = string | number;interfaceWrapperOne {type : 'wrapperOne';value :ValidValue ;info : string[];}interfaceWrapperTwo {type : 'wrapperTwo';value :ValidValue ;extra : number;}typeWrapper =WrapperOne |WrapperTwo ;
In this example lets say the wrappers should both hold the same value type (both string or both number). But with this definition you could do
ts
constwrappedValues :Wrapper [] = [{type : 'wrapperOne',value : 'aVal', // this wrapper is using a stringinfo : ['extra info', 'more info'],},{type : 'wrapperTwo',value : 1, // this wrapper is using a numberextra : 10,},];
Since the value
field can be string | number
there is nothing stopping a user of this type to mix and match the wrapped value types in the objects of the array.
Generics can be used to "lock" the value type in for all elements of the array.
ts
typeValidValue = string | number;interfaceWrapperOne <T extendsValidValue > {type : 'wrapperOne';value :T ;info : string[];}interfaceWrapperTwo <T extendsValidValue > {type : 'wrapperTwo';value :T ;info : number;}typeWrapper <T extendsValidValue > =WrapperOne <T > |WrapperTwo <T >;
Now when we say we have a Wrappers<string>
both wrapper's value
type will be string.
ts
constwrappedValues :Wrapper <string>[] = [{type : 'wrapperOne',value : 'aVal', // this wrapper is using a stringinfo : ['extra info', 'more info'],},{type : 'wrapperTwo',value : 'I can only use string', // using a number here would now throw an errorinfo : 10,},];
Mapped Types
Mapped Types are a specific kind of generic types to help you build out new types.
You may have seen is the Record<K,V>
type, this lets you define an object whose keys are in the type K
and values are in the type V
. You can define your own record type like so.
ts
typeMyRecord <KeyType extends string,ValueType > = {[key inKeyType ]:ValueType ;};constmyRecord :MyRecord <'foo' | 'bar', number | string> = {foo : 10,bar : 'string' };
All mapped types do is iterate over possible values to define new keys (notice the key in KeyType
).
Another common mapped type is Pick<T, Keys>
, this will yield a new type by picking the set of properties (Keys
) from T
. You can define it like so.
ts
typeMyRecord <KeyType extends string,ValueType > = {[key inKeyType ]:ValueType ;};constmyRecord :MyRecord <'foo' | 'bar', number | string> = {foo : 10,bar : 'string' };// --cut--typemyPick <Type ,Keys extends keyofType > = {[key inKeys ]:Type [key ];};// same record from the above exampletypeOnlyFoo =myPick <typeofmyRecord , 'foo'>; // resulting type is {foo: number | string}
Typescript has a number of built-in Utility types, look over them all, they are extremely handy to avoid repeating yourself.
Incrementally Type an object
Mapped types are excellent for incrementally adding types when converting from javascript to typescript. Let's say you are adding types for database tables. When you first converted you may have made a type like type Tables = Record<string, any>
. This doesn't but you much other than saying Tables
is an object.
When you decide it's time to add types for tables you start by typing a simple database table like so
ts
interfaceUserTable {id : number;username : string;}
Now you run into an issue, how can you add just this type to the Tables
type we had before without specifying all your tables? Mapped types can set a default type for anything you have not explicitly set.
ts
typeTables = {users :UserTable ;[key : string]: any;};typeUsers =Tables ['users']; // This is the UserTable type abovetypeOther =Tables ['somethingElse']; // This is any
Now you can add types for just tables you have manually typed while allowing old code to still reference any untyped table.
Using Mapped Types instead of Enums
Typescript added Enums to Javascript, personally I would avoid them. There is some debate on how useful enums are, I will avoid that discussion here and show how what I do instead.
I like to use an as const
object to hold my enum like types.
ts
constUserStates = {guest : 'guest',loggedIn : 'loggedIn',paid : 'paid',} asconst ;typeUserStatesMap = typeofUserStates ;typeUserStates =UserStatesMap [keyofUserStatesMap ]; // UserStates is now 'guest' | 'loggedIn' | 'paid'functionhandleState (state :UserStates ) {}handleState (UserStates .guest );
Now this looks like more code than just using a typescript enum, but this buys us a few things. Since the type is just an object map we can use mapped types to modify/filter our types as we need. Let's say we wanted to filter our UserStates
type to not include guest
accounts. We could do the following
ts
typeNonGuest = {[key in keyofUserStatesMap ]:key extends 'guest' ? never :UserStatesMap [key ];}[keyofUserStatesMap ]; // This type resolves to `'loggedIn' | 'paid'`
In this example we actually used something called a Conditional Type to filter our the guest
type. These types basically provide the ternary operator to the typescript type system. In this example if the key extends 'guest'
its value in the resulting object would be never
. Then when we get all the values of the map the never
type is dropped from the union.
Conditional and Mapped types show up together a lot. They can be very powerful to modify existing types. They allow you to avoid redefining types for small changes.
I plan on covering Conditional Types in more detail in my next post!
Wrap up
Generics and Mapped Types are extremely powerful tools to help types follow through your program and to avoid repeating yourself. They can be a little confusing to newcomers so be sure to comment them well.