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
- Generics (TypeScript Handbook)
- Type Assertions (TypeScript Handbook)
- Type Assertion (Basarat's TypeScript Deep Dive)
- TypeScript Playground
Feedback?
I'd love to hear your thoughts. Do you have other use cases? examples?
Photo by Joshua Coleman on Unsplash
Top comments (6)
Great write up, thanks! I’m recently doing more and more typescript (coming from Java) and it’s sometimes confusing to get things right. Although I would also use a type it’s pretty handy for tests to use objects as types as you’ve shown.
The one thing that bugs me the most about the TS type system is the type erasure at runtime. I’ve had some bugs where while debugging I had a string in a variable of type int...
Hi Jan, Thank you for reading and commenting.
Having a string on a variable of type number is unfortunately possible because there's no TypeScript at runtime; only JavaScript.
If your TS configuration is strict this should not be normally possible, unless of course, you're loading and parsing external data at runtime, which is very common.
For instance, you expect an object's id to be of type number but the JSON response from the server has it as a string.
One way to avoid this is to validate the server response at runtime. You can do this using something like ts.data.json or io-ts
Here's an article on dev.to showing how to use ts.data.json
In my case, it was an input element that was bound to the component in Angular. The types were correct, but the form returned strings...
Ah. I'm afraid I don't have enough experience with Angular. I suppose there are other reasons for that miss-type to happen: a library could be exporting the wrong types.
You can see it in the dom, that all form values are strings in the browser. There are no types. When you bin an integer to a form input, it will be a string eventually. When the user modifies it, the new string goes back to the model.
I really wish there were some statically typed version of TS that would be compiled to WebASM... maybe that would be a way to circumvent the JS quirks at runtime.
Nice example 👍