Post banner

Supercharge your React Project with TypeScript Generics!

12 min read
Table of Contents

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.

Generic Headache

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.

TS Docs

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 with role 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 with status 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 given key appear.
  • returns a key-value object where the key is a string, and the value 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, ensuring T is not a primitive type like string or number. For example, if T is { id: number, name: string }, then K 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: The key parameter must be a valid property of T, preventing invalid keys from being used. See below example

    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: "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 type T .

    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 in array[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!