Getting to Know TypeScript Generics

Bryan

Getting to Know TypeScript Generics

Introduction

DRY. Do Not Repeat Yourself. DRY is an acronym that became synonymous with engineering fundamentals. Using DRY, we ensure that we don’t find ourselves with the same lines of code repeated over and over in a repository; indeed when the inevitable need arises to add a single tweak, we come across the predicament of having to go back to each-and-every-single-repeated piece of code to update each individually.

Repeating code was a challenge we quickly came to face during an engagement with a customer where we needed to implement card component selection functionality using React Hooks. The reason we had to repeat ourselves, was that in multiple instances we needed to implement the card selection state to handle multiple data types (string, number, etc.). While in the midst of the ctrl+c, ctrl+p routine, we decided to refactor our components to be more maintainable and properly “reusable” to embrace React’s full capability of custom hooks, while still allowing us to keep our type safety in the realm of TypeScript.

Thus, we had the opportunity to leverage TypeScript Generics as a way to keep DRY, increase maintainability, and keep in the recommended pattern of React Reusable components.

Below, this post will take you through examples of where TypeScript Generics can help DRY up your code, while demonstrating both a generic TypeScript function sample and a React Custom Hook example that follows a closer approach that was used during our customer engagement.

TypeScript Generic Overview

A TypeScript generic is a way of creating reusable components that can work with different types of data rather than one. It allows you to specify a placeholder type that represents the actual type that will be used when the code is executed. For example, you can define an array as Array<T> where T is any type you want.

TLDR Sample

If you prefer to play around with an example, refer to this Code Sandbox React Hook Example

Problem Example

Let’s say that we have two functions that virtually do the same thing, however the parameter may have multiple types. Let’s say that we have a business, and we sell bikes and tennis racquets. Now, both items are different, albeit they both have a price tag attached to them.

So we have a bike type:

type Bike = {
  price: number;
  name: string;
  productType: number; //enum 1 - Electric, 2 - Mechanical
  tires: string;
  weight: number;
  length: number;
  height: number;
}

and a tennis racquet type:

type TennisRacquet = {
  name: string;
  price: number;
  brand: number;  // enum 1 - Babolat,  2 - Head, 3- Wilson
  weight: number;
  gripSize: number;
  size: number;
  racquetStrings: []string;
}

Both Bike and TennisRacquet are pretty different in types, however they have a price in common. To find the average cost of our TennisRacquets and Bikes, we have 2 functions:

// Average Bike Cost
const averageBikeCost: number = (bikes: Bike[]) => {
    const sum: number = bikes.map(bike => bike.price).reduce((val, sum) => val + sum, 0);
    const average = sum / bikes.length;
    return average
}

// Average Tennis Racquet Cost
const averageTennisRacquetCost: number = (tennisRacquets: TennisRacquet[]) => {
    const sum: number = tennisRacquets.map(tennisRacquet => tennisRacquet.price).reduce((val, sum) => val + sum, 0);
    const average = sum / tennisRacquets.length;
    return average
}

Both these functions look fairly identical, except for the type. We could combine these two functions, without much difficulty since both just use the price.

const averageProductCost: number = (products: TennisRacquet[] | Bike[]) => {
    const sum: number = products.map(product => product.price).reduce((val, sum) => val + sum, 0);
    const average = sum / products.length;
    return average
}

However, to introduce a bit more complex functionality to get a mapping of the average of each “type” of racquet and bike, E.g. The average cost of all the electric bikes, and the average cost of all Babolat racquets.

If we want to wrap this in one function, countSumMap, we will have to type narrow as shown below.

Now Imagine if we want to extend this by another type if we add to our inventory of products (E.g., type Shoes). We would be increasing this example quite a bit, and it will feel very repetitive

const countSumMap: Map<string, { count: number; sum: number }> = (prods: Tennis[] | Bike[]) => {
    const newMap = new Map<string, { count: number; sum: number }>();
    if ("tires" in prods[0]) {
        prods.map(prod => {
          if (newMap.has(prod.productType)){
              const { count, sum } = newMap.get(prod.productType)
              newMap.set(prod.productType, { count: count + 1, sum: prod.price + sum });
          } else {
              newMap.set(prod.productType, { count: 1, sum: prod.price });
          }
        })
    } else if ("handleSize" in prods[0]) {
        prods.map(prod => {
            if (newMap.has(prod.racquetBrand)) {
                const { count, sum } = newMap.get(prod.racquetBrand)
                newMap.set(prod.racquetBrand, { count: count + 1, sum: prod.price + sum });
            } else {
                newMap.set(prod.racquetBrand, { count: 1, sum: prod.price });
            }
        })
    } else if ("shoeSize" in prods[0]) {
        // ... conditional logic for type Shoe 
    }
    return newMap
}

const tennisRacquetsCountPriceMap = countSumMap(tennisRacquets);
// Map(1) { 'Babolat' => { count: 1, sum: 150 } }
// ... extra logic to find average / mean price
const bikeCountPriceMap = countSumMap(bikes)
// Map(1) { 'Electric' => { count: 2, sum: 1500 } }
// ... extra logic to find average / mean price

This is where Typescript Generics come in handy!

Using Generics

We can use a type Generic <T> to help us reduce this repetitive code.

Notice the code shown below, this is much more concise, and also reusable. countSumMap can be used for any object with a price. keyName is given to allow us to define how we organize our summation and count.

const countSumMap: Map<string, { count: number; sum: number }> = <T>(prods: T[], keyName: string) => {
    if (!keyName) return new Map();

    const newMap = new Map<string, { count: number; sum: number }>();
        prods.map(prod => {
            if (newMap.has(prod[keyName])){
                const { count, sum } = newMap.get(prod[keyName])
                newMap.set(prod[keyName], { count: count + 1, sum: prod.price + sum });
            } else {
                newMap.set(prod[keyName], { count: 1, sum: prod.price });
            }
        })
    return newMap
}
// console.log(countSumMap(bikes, "productType"))
// Map(2) {
//   'Electric' => { count: 2, sum: 1500 },
//   'Mechanical' => { count: 1, sum: 3000 }
// }

countSumMap will infer <T>, as whatever type we pass as our parameter to prods. If our countSumMap takes an argument of Bike[], then it will be able to handle the logic while inferring Bike as the type used in the function body. The same goes with TennisRacquet.

Another Example – React Custom Hook

NOTE: For a Full Working Example, See this Code Sandbox Demo

For this example, assume that we are attempting to set up an item selection that allows us to define a multiSelect mode, so we can either add a single item as our activeItem, or we can select multiple.

  • Think of holding shift to select multiple.

The first step will be to create a custom hook that will allow us to create a React hook that can receive any type in a Set, and allow items to be stored and modified in that Set. Our custom hook will also receive multiSelect boolean to define if we want to do a single active item, or multi select.

import { useState } from "react";

interface ItemState<T> {
    selectedItems: Set<T>;
}
interface ItemActions<T> {
    handleItemSelect: (item: T, multiSelectMode: boolean) => void;
}
export const useSelect = <T>(items: Set<T>): [ItemState<T>, ItemActions<T>] => {
    const [selectedItems, setSelectedItems] = useState<typeof items>(items);

    const handleItemSelect = (
        item: T,
        multiSelectMode: boolean = false
    ): void => {
        const copiedSet = new Set<T>(selectedItems);
        if (copiedSet.has(item)) {
            copiedSet.delete(item);
        } else {
            if (!multiSelectMode) {
                const newSet = new Set<T>();
                newSet.add(item);
                setSelectedItems(newSet);
                return;
            }
            copiedSet.add(item);
        }
        setSelectedItems(copiedSet);
    };

    return [
        {
            selectedItems,
        },
        {
            handleItemSelect,
        },
    ];
};

To continue the concept of Bikes and TennisRacquets, if we have two distinct lists of items for our Bikes and Racquets, we could use the above code sample to handle selection for both types.

Tennis Racquet Example

import { useSelect } from "../hooks";

function TennisRacquetList({ racquets }: { racquets: ITennisRacquet[] }) {
    const [{ selectedItems }, { handleItemSelect }] = useSelect(
        new Set<string>()
    );
    return (
        <div className="card-wrapper">
            {racquets.map((racquet) => (
                <TennisRacquet
                  key={racquet.GUID}
                  isActive={selectedItems.has(racquet.GUID)}
                  tennisRacquet={racquet}
                  selectOne={handleItemSelect}
                  multiSelect={handleItemSelect}
                />
            ))}
        </div>
    );
}

export default TennisRacquetList;

Bike Example

import { useSelect } from "../hooks";

function Bikes({ bikes }: { bikes: IBike[] }) {
    const [{ selectedItems }, { handleItemSelect }] = useSelect(
        new Set<number>()
    );
    return (
        <div className="card-wrapper">
            {bikes.map((bike) => (
                <Bike
                  key={bike.id}
                  isActive={selectedItems.has(bike.id)}
                  bike={bike}
                  selectOne={handleItemSelect}
                  multiSelect={handleItemSelect}
                />
            ))}
        </div>
    );
}

export default Bikes;

Summary

Generic Typescript types allow reusable functionality for logic that may be very similar but only differ by type. Instead of needing to create type predicates or type narrowing in this use-case, we can instead use TypeScript Generics to achieve reusability, and maintainability in the instance that another type is requesting this same functionality.

References

Acknowledgements

A special thanks to the wonderful team behind this engagement and learning: Bryan, Ivan, Shreyas, Maggie, Bret, Michael, and Maarten.

Feedback usabilla icon