Nadia Makarevich
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:
-
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.
-
onChange
handler returns only theid
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 -
it's completely not typesafe, and very easy to make a mistake. Once she used
doSomethingWithBooks
handler on a select withmoviesOptions
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 sideoptions
andonChange
values should be connected; so that if she usesdoSomethingWithBooks
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:
It 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;}
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" typeconst 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 booleanconst 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} />
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} /></>);};
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! âđŒ