shaky.sh

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) have firstName: 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.