Julian​Garamendy​.dev

Info  |  Work  |  Blog

A use case for TypeScript Generics

August 15, 2019

duck typing

The following may be a bit obvious for some. But something just clicked in my head and I thought I’d write it down.

When generics are a good idea

Imagine we write a function that returns the “oldest” item in a set/array:

function getOldest(items: Array<{ age: number }>) {
  return items.sort((a, b) => b.age - a.age)[0];
}

This function can be called with an array of any kind of objects as long as they contain an age property of type number.

To help reasoning about it, let’s give this a name:

type HasAge = { age: number };

Now our function can be annotated like this:

function getOldest(items: HasAge[]): HasAge {
  return items.sort((a, b) => b.age - a.age)[0];
}

Great! Now we can use this function with any objects that conform to the HasAge interface:

const things = [{ age: 10 }, { age: 20 }, { age: 15 }];
const oldestThing = getOldest(things);

console.log(oldestThing.age); // 20 ✅

Because the type of oldestThing is inferred to be HasAge, we can access its .age property.

But what if we have more complex types?

type Person = { name: string, age: number};

const people: Person[] = [
  { name: 'Amir', age: 10 }, 
  { name: 'Betty', age: 20 }, 
  { name: 'Cecile', age: 15 }
 ];

const oldestPerson = getOldest(people); // 🙂 no type errors

This works, but now the inferred type of oldestPerson is HasAge. We’ve lost the Person type along the way. As a result we can’t (safely) access it’s .name property.

console.log(oldestPerson.name); // ❌ type error: Property 'name' does not exist on type 'HasAge'.

Annotating oldestPerson:Person won’t work:

const oldestPerson: Person = getOldest(people); // ❌ type error
// Property 'name' is missing in type 'HasAge' but required in type 'Person'.

We could use type assertions, but it’s not a good idea. (why?)

const oldestPerson = getOldest(people) as Person; // 🚩
console.log(oldestPerson.name); // no type error

You can try this in TypeScript Playground.

Using Generics

We can do better. We can turn this into a generic function.

function getOldest<T extends HasAge>(items: T[]): T {
  return items.sort((a, b) => b.age - a.age)[0];
}

const oldestPerson = getOldest(people); // ✅ type Person

Success!! Now the inferred type of oldestPerson is Person!
As a result we can access its .name property.

Here’s another example with 2 different types:

type Person = {name: string, age: number};
const people: Person[] = [
  { name: 'Amir', age: 10 }, 
  { name: 'Betty', age: 20 }, 
  { name: 'Cecile', age: 15 }
 ];

type Bridge = {name: string, length: number, age: number};
const bridges = [
{ name: 'London Bridge', length: 269, age: 48 },
{ name: 'Tower Bridge', length: 244, age: 125 },
{ name: 'Westminster Bridge', length: 250, age: 269 }
]

const oldestPerson = getOldest(people); // type Person
const oldestBridge = getOldest(bridges); // type Bridge

console.log(oldestPerson.name); // 'Betty' ✅
console.log(oldestBridge.length); // '250' ✅

You can try this in TypeScript Playground

When generics are not needed

Even if your function takes objects conforming to the HasAge interface; as long as you don’t mean to return the same type you don’t need generics.

function isFirstOlder<T extends HasAge>(a: T, b: T) {
  return a.age > b.age;
}

The function above doesn’t need to be generic. We can simply write:

function isFirstOlder(a: HasAge, b: HasAge) {
  return a.age > b.age;
}

Resources


Julian Garamendy

Written by Julian Garamendy