Typescript generics for React developers

I don’t know about you, but I fall asleep every time I’m trying to read typescript documentation. There is something in the way it is written that signals to my brain that I should not even attempt to comprehend it until I had a good night’s sleep, three coffees, and ideally some chocolate to stimulate the brain cells. I think I now found my purpose for the next few months: I want to re-write typescript documentation in a way that is actually understandable by a casual reader 😊

Let's start with one of the pain points many developers are struggling with: generics! And we’re going to start with a bottom-up approach: let’s implement a component without generics, and introduce them only when we need them.

Intro

Introducing: Judi 👩🏽‍💻. Judi is a very ambitious developer and wants to implement her own online shop, a competitor to Amazon. She will sell everything there: books, movies, more than a thousand types of various categories of goods. And now she’s at the stage she needs to implement a page with a bunch of identical-looking selects for multiple categories of goods on the page.

She starts very simple: a select component, that accepts an array of options with value and title to render those, and an onChange handler so that she can do something when a value in a select is changed (every select will do different things!).

import React from 'react';

type SelectOption = {
  value: string;
  label: string;
};

type SelectProps = {
  options: SelectOption[];
  onChange: (value: string) => void;
};

export const Select = ({ options, onChange }: SelectProps) => {
  return (
    <select onChange={(e) => onChange(e.target.value)}>
      {options.map((option) => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
};

This seems like an okay solution for the purpose: she can re-use those selects for all her products and take over the online shopping world.

<>
  <Select option={bookOptions} onChange={(bookId) => doSomethingWithBooks(bookId)} />
  <Select option={movieOptions} onChange={(movieId) => doSomethingWithMovies(movieId)} />
</>

Unfortunately, as the shop grew, she found a few problems with this solution:

  1. the select component accepts options in a very specific format, everything needs to be converted to it by the consumer component. And as the shop grows, more and more pages begin to use it, so that conversion code started to bleed all over the place and became hard to maintain.

  2. onChange handler returns only the id of the changed value, so she needed to manually filter through arrays of data every time she needed to find the actual value that has changed

  3. it's completely not typesafe, and very easy to make a mistake. Once she used doSomethingWithBooks handler on a select with moviesOptions by mistake, and that blew up the entire page and caused an incident. Customers were not happy 😞

💪 Time to refactor

Judi wanted to significantly improve her application and:

  • get rid of all the code that filters through the arrays of raw data here and there
  • remove all the code that was generating the select options everywhere
  • make the select component type-safe, so that next time she uses the wrong handler with a set of options, the type system could catch it

She decided, that what she needs is a select component that:

  • accepts an array of typed values and transforms it into select options by itself
  • onChange handler returns the “raw” typed value, not just its id, hence removing the need to manually search for it on the consumer side
  • options and onChange values should be connected; so that if she uses doSomethingWithBooks on a select that accepted movies as value, it would’ve been caught by the type system.

She already had all her data typed, so only the select component needed some work.

export type Book = {
  id: string;
  title: string;
  author: string; // only books have it
};

export type Movie = {
  id: string;
  title: string;
  releaseDate: string; // only movies have it
};
... // all other types for the shop goods

Strongly typed select - first attempt

Judi, again, started simple: she decided that she’ll implement a select that accepts only books for now, and then just modify it to accept the rest of the types afterwards.

type BookSelectProps = {
  values: Book[];
  onChange: (value: Book) => void;
};

export const BookSelect = ({ values, onChange }: BookSelectProps) => {
  const onSelectChange = (e) => {
    const val = values.find((value) => value.id === e.target.value);

    if (val) onChange(val);
  };
  return (
    <select onChange={onSelectChange}>
      {values.map((value) => (
        <option key={value.id} value={value.id}>
          {value.title}
        </option>
      ))}
    </select>
  );
};

This looked great already: now she doesn’t need to worry about mixing handlers or values up, this select accepts only Books are properties and always returns a Book when a value is changed.

Now, all she needs to do is turn BookSelect into GenericSelect and teach it how to deal with the rest of the data in the app. First, she just tried to do a union type on the values (if you’re not familiar with those - it’s just a fancy word for or operator for types)

But it was almost instantly obvious to her, that this is not a very good idea. Not only because she’d have to manually list all supported data types in the select and change it every single time a new data type is added. But it actually made things worst from the code complexity perspective: typescript doesn’t actually know what exactly is passed in the onChange callback with this approach, regardless of what goes into the values. So even the most obvious and simple use case of logging the author of the selected book will make typescript super confused:

t knows, that in value there can be either Book or Movie, but it doesn’t know what exactly is there. And since Movie doesn’t have an author field, typescript will consider the code above an error.

Strongly typed select - actual solution with typescript generics

And this is finally where typescript generic types could come in handy. Generics, in a nutshell, are nothing more than a placeholder for a type. It’s a way to tell typescript: I know I will have a type here, but I have no idea what it should be yet, I’ll tell you later. The simplest example of a generic, used in the documentation, is this:

function identity<Type>(a: Type): Type {
  return a;
}

which translates roughly into: “I want to define a function that accepts an argument of some type and returns a value of exactly the same type. And I will tell you later which type it is.”

And then later in the code, you can just tell this function what exactly you meant by this placeholder type:

const a = identity<string>("I'm a string") // "a" will be a "string" type
const b = identity<boolean>(false) // "b" will be a "boolean" type

And then any attempt to mistype it will fail:

const a = identity<string>(false) // typescript will error here, "a" can't be boolean
const b = identity<boolean>("I'm a string") // typescript will error here, "b" can't be string

So the way to apply this to the select component is this:

Now, I intentionally don’t include code in a copy-pasteable form here, because this example is actually not going to work 😅. The first reason is very React in Typescript specific: since this is a React component, typescript will assume that the very first <Tvalue> is a jsx element and will fail. The second reason is exclusively generics problem: when we try to access value.title or value.id in our select, typescript at this point still doesn’t know which type we have in mind for this value. It has no idea which properties our value can have and rightfully so. Why would it?

This leads us to the last piece of this puzzle: generic constraints.

Constraints are used to narrow down the generic type so that typescript can make at least some assumptions about TValue. Basically, it’s a way to tell typescript: I have no idea what TValue should be yet, but I know for a fact that it will always have at least id and title, so you’re free to assume they will be there.

And now the select component is complete and fully functional! 💥 🎉 Check it out:

type Base = {
  id: string;
  title: string;
};

type GenericSelectProps<TValue> = {
  values: TValue[];
  onChange: (value: TValue) => void;
};

export const GenericSelect = <TValue extends Base>({ values, onChange }: GenericSelectProps<TValue>) => {
  const onSelectChange = (e) => {
    const val = values.find((value) => value.id === e.target.value);

    if (val) onChange(val);
  };

  return (
    <select onChange={onSelectChange}>
      {values.map((value) => (
        <option key={value.id} value={value.id}>
          {value.title}
        </option>
      ))}
    </select>
  );
};

And Judi finally can use it to implement all the selects that she wants for her Amazon competitor:

// This select is a "Book" type, so the value will be "Book" and only "Book"
<GenericSelect<Book> onChange={(value) => console.log(value.author)} values={books} />

// This select is a "Movie" type, so the value will be "Movie" and only "Movie"
<GenericSelect<Movie> onChange={(value) => console.log(value.releaseDate)} values={movies} />

Typescript generics in React hooks bonus

Did you know that most React hooks are generics as well? You can explicitly type things like useState or useReducer and avoid unfortunate copy-paste driven development mistakes, where you define const [book, setBook] = useState(); and then pass a movie value there by accident. Things like that could cause a little crash of reality for the next person who reads the code and sees setBook(movie) during the next refactoring.

This will work fine, although will cause a lot of rage and despair for anyone who’s trying to fix a bug with this setup:

export const AmazonCloneWithState = () => {
  const [book, setBook] = useState();
  const [movie, setMovie] = useState();

  return (
    <>
      <GenericSelect<Book> onChange={(value) => setMovie(value)} values={booksValues} />
      <GenericSelect<Movie> onChange={(value) => setBook(value)} values={moviesValues} />
    </>
  );
};

This will prevent it, and any malicious attempt to use setBook on a value in the second select will be stopped by typescript:

export const AmazonCloneWithState = () => {
  const [book, setBook] = useState<Book | undefined>(undefined);
  const [movie, setMovie] = useState<Movie | undefined>(undefined);

  return (
    <>
      <GenericSelect<Book> onChange={(value) => setBook(value)} values={booksValues} />
      <GenericSelect<Movie> onChange={(value) => setMovie(value)} values={moviesValues} />
    </>
  );
};

That’s all for today, hope you enjoyed the reading and generics are not a mystery anymore! ✌🏼

Check out the blog for more articles like this: https://www.developerway.com/

24