If you code React with TypeScript, you might have heard about this cool thing called Generics. Everyone talks about it, everyone says its important, yet few give real-world examples of how to use it in a real React project.
Even in the official TypeScript docs itself (see below), the explanations about Generics are very abstract. Most of the examples are too theoretical, that make understanding it is so much more complicated than it needs to be. Let alone on how to use it in React.
If you’ve been banging your head whenever you meet this TypeScript Generics thing, then you are at the right place! I will explain everything with real-world React examples to you!
Hopefully in the next 10-15 minutes, you will go from Generics noob to Supercharged React developer! Let’s get on with it, shall we 👊
Demystifying generics - with examples
Generics in TypeScript allows developers to write code that operates on types specified at a later point, rather than hardcoding specific types upfront.
Simply says, Generic is like a template. You define the structure of your code once, and then plug in whatever type that matches the template when you want to use it.
Confused? No worries. The easiest way to understand it is by examples, and let’s discuss lots of it going forward.
For now, let’s understand how we would do things if we don’t have Generics.
Doing things without generics
In most production React apps, we might have up to 50 or maybe 100+ json data in any kind of shapes. Starting from the API responses, to the data we render in UIs.
One of the most popular use cases in React app is, to count values by key property in a JSON data, for summary purposes. For example, we can have users
data like this:
type User = {
id: number;
role: "admin" | "user" | "guest"; // NOTE: We'll discuss this later
name: string;
};
const users: User[] = [
{ id: 1, role: "admin", name: "Alice" },
{ id: 2, role: "user", name: "Bob" },
{ id: 3, role: "guest", name: "Charlie" },
{ id: 4, role: "admin", name: "David" },
{ id: 5, role: "user", name: "Eve" },
];
Let’s say we need to count number of users
with certain roles. Normally, here’s what we will do:
function countUsersByRole(array: User[]): Record<string, number> {
return array.reduce(
(acc, user) => {
const role = user.role;
acc[role] = (acc[role] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
}
const roleCounts = countUsersByRole(users);
console.log(roleCounts);
// Output: { admin: 2, user: 2, guest: 1 }
This function does the followings:
- takes an array of
User
as an input and returns an object withrole
as the key and the count of each role as the value. - counts how many users belong to each role in the given array.
- uses
reduce
to loops through the array, extracts each user’s role, and increments its count in the accumulator, which starts as an empty object.
Note: see footnote about Record [^1]
Let’s say now we have another data for orders
, it looks like this:
type Order = {
orderId: string;
status: "pending" | "shipped" | "delivered" | "cancelled";
total: number;
};
const orders: Order[] = [
{ orderId: "A1", status: "pending", total: 100 },
{ orderId: "A2", status: "shipped", total: 200 },
{ orderId: "A3", status: "pending", total: 150 },
{ orderId: "A4", status: "delivered", total: 300 },
{ orderId: "A5", status: "shipped", total: 120 },
{ orderId: "A6", status: "pending", total: 80 },
{ orderId: "A7", status: "delivered", total: 250 },
{ orderId: "A8", status: "cancelled", total: 0 },
];
Now, we need to calculate total orders by status. Without generic, we will do similar things with the previous users
data:
function countOrdersByStatus(array: Order[]): Record<string, number> {
return array.reduce(
(acc, order) => {
const status = order.status;
acc[status] = (acc[status] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
}
// Usage
const statusCounts = countOrdersByStatus(orders);
console.log(statusCounts);
// Output: { pending: 3, shipped: 2, delivered: 2, cancelled: 1 }
This function does the following:
- takes an array of
Order
as an input and returns an object withstatus
as the key and the count of each status as the value. - counts how many orders fall under each status category in the given array.
- uses
reduce
to iterate through the array, extracts each order’s status, and increments its count in the accumulator, which starts as an empty object.
Thinking through the examples - finding similarities
If you take a look closely, the countUsersByRole
and thecountOrdersByStatus
functions are very similar. They take an array of object, uses the same reduce
function to operate, and finally return object key-value of type Record<string, number>
. The only differences are in the key used to categorize the data.
// countUsersByRole
const role = user.role;
acc[role] = (acc[role] || 0) + 1;
// Extracts the `role` property from each user and uses it as the key in the accumulator.
// If the role exists, its count increases by 1; otherwise, it starts at 1.
// countOrdersByStatus
const status = order.status;
acc[status] = (acc[status] || 0) + 1;
// Extracts the `status` property from each order and uses it as the key in the accumulator.
// If the status exists, its count increases by 1; otherwise, it starts at 1.
Both functions follow the same pattern, but one categorizes data by role
, while the other categorizes it by status
.
With this in mind, we can generalize their behavior:
- accepts an array of objects,
- count how many time a specific
value
based on a givenkey
appear. - returns a key-value object where the
key
is a string, and thevalue
is a number (i.e.,Record<string, number>
).
So now, instead of writing separate functions for every possible object types, we can just create a reusable template function that handles the generalized behavior.
This is where Generics become essential.
Enter Generics - write once, use everywhere
Let’s refactor the previous functions to become a single “Generic” that accepts an array of object of whatever types, then count values by one key field, and return the key-value object from it.
We will call this generic function as countByKey
, and it looks like this:
/**
* Counts how many times a specific value appears in
* an array of objects based on a given key.
*
* @param {T[]} array - The array of objects to process.
* @param {K} key - The key used to access the value in each object.
* @returns {Record<string, number>} - An object where the keys are the
* values of the specified key, and the values are the number of times
* each value appears.
*
* @template T - The type of objects in the array. It extends `object` to
* ensure that `T` is an object type, not a primitive value like a number
* or string.
* @template K - The key of the object used for counting, which must be
* one of the keys of type T.
*/
function countByKey<T extends object, K extends keyof T>(
array: T[],
key: K,
): Record<string, number> {
return array.reduce(
(acc, item) => {
const keyValue = String(item[key]);
acc[keyValue] = (acc[keyValue] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
}
Don’t worry if this looks intimidating at first—by the end, it will all make sense! Try to read the JSDoc explanations too, it should give you some basic ideas about it.
Breaking down the countByKey
generic function
In the previous example, our function has the following signature:
function countByKey<T extends object, K extends keyof T>(
array: T[],
key: K,
): Record<string, number> {}
This can be read as the followings:
-
T extends object
: The function only works with arrays of objects, ensuringT
is not a primitive type likestring
ornumber
. For example, ifT
is{ id: number, name: string }
, thenK
can only be"id"
or"name"
. See below://========== ✅ CORRECT USAGE ==========// type User = { name: string; age: number; }; const users: User[] = [ { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, { name: "Alice", age: 25 }, ]; countByKey(users, "name"); // ✅ Works: users is an array of objects, and "name" is a valid key. //========== ❌ INCORRECT USAGE ==========// const numbers = [1, 2, 3, 1, 2]; countByKey(numbers, "value"); // ❌ Error: number is not an object, so it does not have any keys. const names = ["Alice", "Bob", "Alice"]; countByKey(names, "length"); // ❌ Error: string is not an object, so it does not match `T extends object`.
-
K extends keyof T
: Thekey
parameter must be a valid property ofT
, preventing invalid keys from being used. See below exampletype User = { name: string; age: number; }; const users: User[] = [ { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, { name: "Alice", age: 25 }, ]; countByKey(users, "name"); // ✅ Works: "name" is a valid key in the objects. countByKey(users, "age"); // ✅ Works: "age" is also a valid key in the objects. countByKey(users, "email"); // ❌ Error: "email" does not exist in the object structure.
-
array: T[]
: The first argument is an array of objects of typeT
.type User = { name: string; age: number; }; const users: User[] = [ { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, ]; countByKey(users, "name"); // ✅ Works: users is an array of User objects. const singleUser: User = { name: "Alice", age: 25 }; countByKey(singleUser, "name"); // ❌ Error: singleUser is a single object, not an array. const mixedArray: (User | string)[] = [ { name: "Alice", age: 25 }, "Not an object", { name: "Bob", age: 30 }, ]; countByKey(mixedArray, "name"); // ❌ Error: mixedArray contains a string, violating T[] which expects objects of type User.
-
Record<string, number>
: The function returns an object where the keys are string representations of the values found inarray[key]
, and the values are the counts of how many times each appears.type User = { name: string; age: number; }; const users: User[] = [ { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, { name: "Alice", age: 25 }, { name: "Charlie", age: 25 }, ]; // Using "name" as the key const nameCount = countByKey(users, "name"); console.log(nameCount); // Returns: { Alice: 2, Bob: 1, Charlie: 1 } // The keys are the names from the "name" property, and the values are counts. // Using "age" as the key const ageCount = countByKey(users, "age"); console.log(ageCount); // Returns: { '25': 3, '30': 1 } // The keys are the string representations of ages, and the values are counts.
Using the countByKey
generic
Now, in the initial example, we have the users
and orders
data. For these two data, we want to calculate the followings:
- number of users in each
role
- number of orders in each
status
With our countByKey
generic, we can get those two values with just one function instead of two:
const roleCounts = countByKey(users, "role");
console.log(roleCounts);
// Output: { admin: 2, user: 2, guest: 1 }
const orderStatusCounts = countByKey(orders, "status");
console.log(orderStatusCounts);
// Output: { pending: 3, shipped: 2, delivered: 2, cancelled: 1 }
You can try the code examples in this TypeScript Playground if you are curious.
Introducing new data object - easy peasy 😋
Now, if you have new data with a completely different object type, you can easily apply the same countByKey
function without needing to rewrite any logic.
Let’s say you have a new dataset with product
information, and you want to count how many products belong to each category.
type Product = {
id: number;
category: "electronics" | "furniture" | "clothing";
name: string;
};
const products: Product[] = [
{ id: 1, category: "electronics", name: "Laptop" },
{ id: 2, category: "furniture", name: "Sofa" },
{ id: 3, category: "electronics", name: "Headphones" },
{ id: 4, category: "clothing", name: "Jacket" },
{ id: 5, category: "furniture", name: "Table" },
];
const productCategoryCounts = countByKey(products, "category");
console.log(productCategoryCounts);
// Output: { electronics: 2, furniture: 2, clothing: 1 }
Even though the dataset and object structure are completely different from the previous users
or orders
examples, the function works exactly the same way.
Using generics with React - a sneak peak
With all the previous explanations, I hope you now have a solid understanding of what generics are and why they’re such a powerful tool in TypeScript!
In React codebases, generics can be applied to many advanced scenarios, such as:
- Custom hooks
- Generic components
- Reusable types
Using generics makes your code cleaner and helps scale projects more efficiently in the long run.
To be honest, I’d love to dive deeper into how generics can be used for these scenarios. However, this article has already become quite long with many technical details. So, I’ll wrap it up here and give you some time to absorb all the concepts for now.
In future articles, I’ll cover how to use generics to build powerful features in React and make scaling your projects easier.
Thanks for reading, and stay tuned for more!