DEV Community

Nadia Makarevich
Nadia Makarevich

Posted on • Edited on • Originally published at developerway.com

Typescript generics for React developers

Originally published at https://www.developerway.com. The website has more articles like this ๐Ÿ˜‰


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.

Image description

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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)} />
</>
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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)

Image description

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:

Image description

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.

See example of this error in codesandbox.

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Image description

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.

Image description

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

Check out the fully working example in codesandbox.

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} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Thatโ€™s all for today, hope you enjoyed the reading and generics are not a mystery anymore! โœŒ๐Ÿผ

...

Originally published at https://www.developerway.com. The website has more articles like this ๐Ÿ˜‰

Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.

Top comments (3)

Collapse
 
haulinh profile image
haulinh

Nice article ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰

Collapse
 
adevnadia profile image
Nadia Makarevich

Good point, thanks!

Collapse
 
lowlifearcade profile image
Sonny Brown

Thank you for the clear article