You’ve heard the term “generics” in TypeScript and it gives you a slight shudder. It did me too, and that’s okay.
“What are Generics in TypeScript?”
“How do I use Generics in TypeScript?”
Those are fair questions, and this post is dedicated to giving you a really simple answer whilst demonstrating generic types to you, and when you should use them.
First, a generic type can be considered a placeholder type, like a variable. It expects a type and that type will then hold the value, inferred by TypeScript.
Let’s assume we have an interface Item
and the data structure:
interface Item {
type: string;
name: string;
}
const data: Item[] = [
{ type: 'food', name: 'Pizza' },
{ type: 'drink', name: 'Coffee' },
{ type: 'food', name: 'Hot Dog' },
];
I’ve written about writing a group function in JavaScript, so we’re going to take it and add some types to it to provide a function signature in TypeScript.
Here’s the pure JavaScript function and the resulting code:
const group = (items, fn) => {
return items.reduce((prev, next) => {
const prop = fn(next);
return {
...prev,
[prop]: prev[prop] ? [...prev[prop], next] : [next],
};
}, {});
};
const { food, drink } = group(data, (item) => item.type);
// ✅ [{ type: 'food', name: 'Pizza' }, { type: 'food', name: 'Hot Dog' }]
console.log(food);
// ✅ [{ type: 'drink', name: 'Coffee' }]
console.log(drink);
We’ve almost covered what a generic in TypeScript is, in terms of thinking about it being a placeholder value, but why should we care?
Generics help us create better utility functions, without having to tightly couple them to our codebase. For instance we have interface Item
and are using the group()
function, which is generic in itself - so you can see how the term “generics” came about.
This also answers the question of “where should we use a generic?”, most likely with a class, function, or method that is reusable with multiple data structures.
The core idea comes from wanting to use TypeScript’s powerful inference to pass information down into your function, and optionally allow us to infer the types we get back out.
So how can we add a generic type? This usually begins with <T>
like so, which simply stands for “type” and is a common pattern used in TypeScript:
const group = <T>(items, fn) => {
return items.reduce((prev, next) => {
//...
}, {});
};
This allows us to then allow TypeScript to infer the type of the data that we pass in, if we then tie it to a function argument. Here let’s say that our generic is in fact an array of those generic types, and also add that T
into our fn
signature:
const group = <T>(items: T[], fn: (item: T)) => {
return items.reduce((prev, next) => {
//...
}, {});
};
The interesting piece now is how to access the properties within the generic type. Notice our function call returns the item.type
:
const { food, drink } = group(data, (item) => item.type);
How, what, when could we even make sure this is type safe and fully dynamic? This is where things get ‘tricky’ and often lose developers.
We need to introduce the keyof
TypeScript keyword, which returns us a union type of all keys. To create another generic type off the back of this, we should introduce the extends
keyword and do this:
const group = <T, P extends keyof T>(items: T[], fn: (item: T): T[P]) => {
return items.reduce((prev, next) => {
//...
}, {});
};
Essentially, P
becomes 'type' | 'name'
which is a union type. The extends
simply means that the type of P
must match it.
You’ll note we’re then providing T[P]
at the end of our function signature, meaning we get autocompletion and type safety:
const { food, drink } = group(data, (item) => item.type); // item.name OR item.type
This is the most entry-level use case of a generic in TypeScript, but a powerful one. You’ll often use them with arrays and object data structures.
We can then provide some types for the rest of the function, here I’m using as unknown as string
to tell TypeScript that the value is first unknown, but must be a string. Without the as unknown
TypeScript will give us an error suggesting to add it.
Are we done? Not quite! We can also use our generic to provide information returned to our function, note how I’ve added {} as Record<string, T[]>
.
A Record
type is fairly simple, known as a mapped type, and essentially is a nicer way of constructing an object to be returned. But the important piece lies in T[]
in passing our data structure type back to us.
How does it work? Check out the comment I’ve added below to see what the inferred type would be:
const group = <T, P extends keyof T>(items: T[], fn: (item: T) => T[P]) => {
return items.reduce((prev, next) => {
const prop = fn(next) as unknown as string;
return {
...prev,
[prop]: prev[prop] ? [...prev[prop], next] : [next],
};
}, {} as Record<string, T[]>);
};
// ✅ const group: <Item, keyof Item>(items: Item[], fn: (item: Item) => string) => Record<string, Item[]>
const { food, drink } = group(data, (item) => item.type);
Try this live StackBlitz embed and hover over the type definitions, and you’ll see your generic type in TypeScript spring to life, giving us some excellent type safety:
🚀 There’s so much more to TypeScript that I can teach you. Fast track your skills overnight with my deep-dive TypeScript Courses and take your skills to the top.
I hope you enjoyed this lesson on TypeScript generics! Remember, they’re a placeholder and we can use some additional superpowers like keyof
to dive inside the inferred types even further.