Understanding typescript generics by example
Typescript generics are a built in language feature using the same principles of generics in other languages like C#, Java, etc.
To understand generics, let's talk about functions.
Functions take parameters...
const getUser = async (id: number) => {
const resp = await fetch(`/api/users/${id}`);
const user = await resp.json();
return user;
}
In this case, the id
is a function parameter.
Well, in some cases, you might want to also pass a type as a parameter too, that's when you can use a generic
One super useful time to use a generic is when creating an SDK for an API.
interface User {
id: number;
name: string;
}
interface Repository<RepoType> {
getById(id: number): RepoType;
}
class UserRepository implements Repository<User> {
async getById(id: number) {
const resp = await fetch(`/api/users/${id}`);
const user = await resp.json();
return user;
}
}
const repo = new UserRepository();
const user = repo.getById(1);
In the above example, we're creating an interface with a generic parameter for the RepoType
. This will allow us to create other types of repositories for different data models, but leveraging the code re-usability of the underlying fetch logic.
A similar thing can be done with just pure functions instead of classes.
async function fetchAPI<DataType>(url: string, opts?: FetchOptions) {
const resp = await fetch(url);
const json = await resp.json();
return json as DataType;
}
const fetchUserById = async (id: number) => {
const user = await fetchAPI<User>(`/api/users/${id}`);
return user;
}
const user = fetchUserById(1);
A lot of examples around the internet are fairly contrived, and also tend to use single letter generic names like <T>
, <P, S>
, etc. While the short name is more terse, it can also make things a little hard to read.
The following is a quick and dirty implementation of the queue data structure. Don't worry too much about that, a queue is basically like waiting in line for a ride at Disney World. You enter the line, first, you're the first person to get on the ride. (First in first out or FIFO).
function createQueue<Type> () {
const queue: Type[] = [];
return {
add(obj: Type) {
queue.push(obj);
},
remove() {
return queue.shift();
},
peek() {
return queue[0];
}
}
}
interface Task {
name: string;
work: () => Promise<void>;
}
const taskQueue = createQueue<Task>();
taskQueue.add({ name: 'thing', work: () => {}});
taskQueue.add({ name: 'another', work: () => {}});
taskQueue.add({ name: 'last thing', work: () => {}});
const task = taskQueue.remove();
assert.equal(task.name, 'thing'); // True!
So, in this case we're able to create a strongly typed queue called taskQueue
when we remove an item from the queue using taskQueue.remove()
, the task
will be a Task
. This means your IDE will be able to properly understand what the task
is as well, which is massivley helpful.
In the above examples, we're manually passing the generic parameter, and you can think of the generic in terms of using "of". For example, the queue above, is a Queue
"of" Task
types, and the UserRepostory
is a Repository
"of" type User
.
It's also possible to infer the generic parameter from the actual function parameters.
const find = function<ItemType>(
items: ItemType[],
fn: (t: ItemType) => boolean
): ItemType | null {
for (const item of items) {
if (fn(item)) {
return item;
}
}
return null;
}
interface Avenger {
name: string;
}
const avengers: Avenger[] = [
{ name: 'iron man' },
{ name: 'hulk' },
{ name: 'thor' }
];
const strongestAvenger = find(
avengers,
(avenger) => avenger.name === 'thor',
);
Here we have a find
function. Notice that the ItemType
is the generic parameter name. When we call the find
function, the ItemType
parameter will automatically get passed the Avenger
type because we said in the function parameters that the items
are of ItemType
. You can use ItemType | null
as the return type too.