Better utility functions with TypeScript Generics
This week I posted a video about the basics of generics in TypeScript. But I had to cut out one of my favourite ways to use generic parameters: flexible utility functions.
It's pretty likely that there's some overlap in the various types in your system. A few examples:
- Types that represent persisted data (i.e. database records) have
id: string, createdAt: Date
. - Types that represent people (
Employee
,SupplierContact
) havefirstName: string, lastName: string
.
Of course, we will have many utility functions to work on these types:
type User = {
// ...
createdAt: Date;
}
function getAge(u: User) {
return Date.now() - u.createdAt.valueOf();
}
This getAge
utility can only take User
objects.
But maybe we have a Product
type that also has createdAt
:
type Product = {
// ...
createdAt: Date;
}
Instead of creating a separate utility for getting the age of a product, we can change the argument type of the existing getAge
function:
// method 1
function getAge(u: { createdAt: Date }) {
return Date.now() - u.createdAt.valueOf();
}
// method 2
type HasCreatedAt = { createdAt: Date };
function getAge(u: HasCreatedAt) {
return Date.now() - u.createdAt.valueOf();
}
You can do this inline or with a type alias, doesn't matter because TypeScript is structural.
Either way, you can now use getAge
with both a User
and a Product
, because both satisfy the function's constraint.
Now, let's say you want to create a utility function for adding age
to the argument: maybe this is part of the hydration step in your REST API.
Something that works just for User
might look like this:
function addAge(u: User): User & { age: number } {
return {
...u,
age: Date.now() - u.createdAt.valueOf()
};
}
To make this work for Product
as well, we need to use a generic parameter in our function:
type HasCreatedAt = { createdAt: Date };
type HasAge = { age: number };
// method 1
function addAge<T>(u: T & HasCreatedAt): T & HasAge {
return {
...u,
age: Date.now() - u.createdAt.valueOf()
};
}
// method 2
function addAge<T extends HasCreatedAt>(u: T): T & HasAge {
return {
...u,
age: Date.now() - u.createdAt.valueOf();
};
}
Now, addAge
will take any T
as long as it includes a createdAt: Date
field.
And, it will return the same shape with an additional age: number
field.
Perfect for both User
and Product
.
I'm sure you can think of several places where generic utilities like this can reduce the amount of code you have to write.
One case that I've run into recently is refactoring.
When refactoring a system, you might have UserOld
and UserNew
types, which has some overlap.
You can likely introduce generic parameters into any old utilities you have, so they can work for your new types as well.