React & TypeScript: use generics to improve your types
October 05, 2021 • 9 min read
Introduction
While TypeScript is a godsend for React developers, its syntax is fairly intimidating to newcomers. I think generics are a big part of that: they look weird, their purpose isn't obvious, and they can be quite hard to parse.
This article aims to help you understand and demystify TypeScript generics in general, and their application to React in particular. They aren't that complex: if you understand functions, then generics aren't that far off.
What are generics in TypeScript?
To understand generics, we'll first start by comparing a standard TypeScript type to a JavaScript object.
// a JavaScript object
const user = {
name: 'John',
status: 'online',
};
// and its TypeScript type
type User = {
name: string;
status: string;
};
As you can see, very close. The main difference is that in JavaScript you care about the values of your variables, while in TypeScript you care about the type of your variables.
One thing we can say about our User
type is that its status
property is too vague. A status usually has predefined values, let's say in this instance it could be either "online" or "offline". We can modify our type:
type User = {
name: string;
status: 'online' | 'offline';
};
But that assumes we already know the kind of statuses there are. What if we don't, and the actual list of statuses changes? That's where generics come in: they let you specify a type that can change depending on the usage.
We'll see how to implement this new type afterward, but for our User
example using a generic type would look like this:
// `User` is now a generic type
const user: User<'online' | 'offline'>;
// we can easily add a new status "idle" if we want
const user: User<'online' | 'offline' | 'idle'>;
What the above is saying is "the user
variable is an object of type User
, and by the way the status options for this user are either 'online' or 'offline'" (and in the second example you add "idle" to that list).
All right, the syntax with angle brackets < >
looks a bit weird. I agree. But you get used to it.
Pretty cool right? Now here is how to implement this type:
// generic type definition
type User<StatusOptions> = {
name: string;
status: StatusOptions;
};
StatusOptions
is called a "type variable" and User
is said to be a "generic type".
Again, it might look weird to you. But this is really just a function! If I were to write it using a JavaScript-like syntax (not valid TypeScript), it would look something like this:
type User = (StatusOption) => {
return {
name: string;
status: StatusOptions;
}
}
As you can see, it's really just the TypeScript equivalent of functions. And you can do cool stuff with it.
For example imagine our User
accepted an array of statuses instead of a single status like before. This is still very easy to do with a generic type:
// defining the type
type User<StatusOptions> = {
name: string;
status: StatusOptions[];
};
// the type usage is still the same
const user: User<'online' | 'offline'>;
If you want to learn more about generics, you can check out TypeScript's guide on them.
Why generics can be very useful
Now that you know what generic types are and how they work, you might be asking yourself why we need this. Our example above is pretty contrived after all: you could define a type Status
and use that instead:
type Status = 'online' | 'offline';
type User = {
name: string;
status: Status;
};
That's true in this (fairly simple) example, but there are a lot of situations where you can't do that. It's usually the case when you want to have a shared type used in multiple instances that each has some difference: you want the type to be dynamic and adapt to how it's used.
A very common example is having a function that returns the same type as its argument. The simplest form of this is the identity function, which returns whatever it's given:
function identity(arg) {
return arg;
}
Pretty simple right? But how would you type this, if the arg
argument can be any type? And don't say using any
!
That's right, generics:
function identity<ArgType>(arg: ArgType): ArgType {
return arg;
}
Once again, I find this syntax a bit complex to parse, but all it's really saying is: "the identity
function can take any type (ArgType
), and that type will be both the type of its argument and its return type".
And this is how you would use that function and specify its type:
const greeting = identity<string>('Hello World!');
In this specific instance <string>
isn't necessary since TypeScript can infer the type itself, but sometimes it can't (or does it wrongly) and you have to specify the type yourself.
Multiple type variables
You're not limited to one type variable, you can use as many as you want. For example:
function identities<ArgType1, ArgType2>(
arg1: ArgType1,
arg2: ArgType2
): [ArgType1, ArgType2] {
return [arg1, arg2];
}
In this instance, identities
takes 2 arguments and returns them in an array.
Generics syntax for arrow functions in JSX
You might have noticed that I've only used the regular function syntax for now, not the arrow function syntax introduced in ES6.
// an arrow function
const identity = (arg) => {
return arg;
};
The reason is that TypeScript doesn't handle arrow functions quite as well as regular functions (when using JSX). You might think that you can do this:
// this doesn't work
const identity<ArgType> = (arg: ArgType): ArgType => {
return arg;
}
// this doesn't work either
const identity = <ArgType>(arg: ArgType): ArgType => {
return arg;
}
But this doesn't work in TypeScript. Instead, you have to do one of the following:
// use this
const identity = <ArgType,>(arg: ArgType): ArgType => {
return arg;
};
// or this
const identity = <ArgType extends unknown>(arg: ArgType): ArgType => {
return arg;
};
I would advise using the first option because it's cleaner, but the comma still looks a bit weird to me.
To be clear, this issue stems for the fact that we're using TypeScript with JSX (which is called TSX). In normal TypeScript, you wouldn't have to use this workaround.
A word of warning on type variable names
For some reason, it's conventional in the TypeScript world to give one letter names to the type variable in generic types.
// instead of this
function identity<ArgType>(arg: ArgType): ArgType {
return arg;
}
// you would usually see this
function identity<T>(arg: T): T {
return arg;
}
Using full words for the type variable name can indeed make the code quite verbose, but I still think that it's way easier to understand than when using the single-letter option.
I encourage you to use actual words in your generic names like you would do elsewhere in your code. But be aware that you will very often see the single-letter variant in the wild.
Bonus: a generic type example from open source: useState
itself!
To wrap up this section on generic types, I thought it could be fun to have a look at a generic type in the wild. And what better example than the React library itself?
Let's have a look at the type definition for our beloved hook useState
:
function useState<S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
You can't say I didn't warn you - type definitions with generics aren't very pretty. Or maybe that's just me!
Anyway, let's understand this type definition step by step:
- We begin by defining a function,
useState
, which takes a generic type calledS
. - That function accepts one and only one argument: an
initialState
.- That initial state can either be a variable of type
S
(our generic type), or a function whose return type isS
.
- That initial state can either be a variable of type
useState
then returns an array with two elements:- The first is of type
S
(it's our state value). - The second is of the
Dispatch
type, to which the generic typeSetStateAction<S>
is applied.SetStateAction<S>
itself is theSetStateAction
type with the generic typeS
applied (it's our state setter).
- The first is of type
This last part is a bit complicated, so let's look into it a bit further.
First up, let's look up SetStateAction
:
type SetStateAction<S> = S | ((prevState: S) => S);
All right so SetStateAction
is also a generic type that can either be a variable of type S
, or a function that has S
as both its argument type and its return type.
This reminds me of what we provide to setState
, right? You can either directly provide the new state value, or provide a function that builds the new state value off the old one.
Now what's Dispatch
?
type Dispatch<A> = (value: A) => void;
All right so this simply has an argument of type whatever the generic type is, and returns nothing.
Putting it all together:
// this type:
type Dispatch<SetStateAction<S>>
// can be refactored into this type:
type (value: S | ((prevState: S) => S)) => void
So it's a function that accepts either a value S
or a function S => S
, and returns nothing.
That indeed matches our usage of setState
.
And that's the whole type definition of useState
! Now in reality the type is overloaded (meaning other type definitions might apply, depending on context), but this is the main one. The other definition just deals with the case where you give no argument to useState
, so initialState
is undefined
.
Here it is for reference:
function useState<S = undefined>(): [
S | undefined,
Dispatch<SetStateAction<S | undefined>>
];
Using generics in React
Now that we've understood the general TypeScript concept of generic types, we can see how to apply it in React code.
Generic types for React hooks like useState
Hooks are just normal JavaScript functions that React treats a bit differently. It follows that using a generic type with a hook is the same as using it with a normal JavaScript function:
// normal JavaScript function
const greeting = identity<string>('Hello World');
// useState
const [greeting, setGreeting] = useState<string>('Hello World');
In the examples above you could omit the explicit generic type as TypeScript can infer it from the argument value. But sometimes TypeScript can't do that (or does it wrongly), and this is the syntax to use.
We'll see a live example of that in the next section.
Generic types for Component props
Let's say you're building a Select
component for a form. Something like this:
import { useState, ChangeEvent } from 'react';
function Select({ options }) {
const [value, setValue] = useState(options[0]?.value);
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(event.target.value);
}
return (
<select value={value} onChange={handleChange}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export default Select;
// `Select` usage
const mockOptions = [
{ value: 'banana', label: 'Banana 🍌' },
{ value: 'apple', label: 'Apple 🍎' },
{ value: 'coconut', label: 'Coconut 🥥' },
{ value: 'watermelon', label: 'Watermelon 🍉' },
];
function Form() {
return <Select options={mockOptions} />;
}
Let's say that for the value
of the options we can accept either a string or a number, but not both at the same time. How would you enforce that in the Select
component?
The following doesn't work the way we want, do you know why?
type Option = {
value: number | string;
label: string;
};
type SelectProps = {
options: Option[];
};
function Select({ options }: SelectProps) {
const [value, setValue] = useState(options[0]?.value);
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(event.target.value);
}
return (
<select value={value} onChange={handleChange}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
The reason it doesn't work is that in one options
array you could have an option with a value of type number, and another option with a value of type string. We don't want that, but TypeScript would accept it.
// this would work with the previous `Select`
const mockOptions = [
{ value: 123, label: 'Banana 🍌' },
{ value: 'apple', label: 'Apple 🍎' },
{ value: 'coconut', label: 'Coconut 🥥' },
{ value: 'watermelon', label: 'Watermelon 🍉' },
];
The way to enforce the fact that we want either a number or an integer is by using generics:
type OptionValue = number | string;
type Option<Type extends OptionValue> = {
value: Type;
label: string;
};
type SelectProps<Type extends OptionValue> = {
options: Option<Type>[];
};
function Select<Type extends OptionValue>({ options }: SelectProps<Type>) {
const [value, setValue] = useState<Type>(options[0]?.value);
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
setValue(event.target.value);
}
return (
<select value={value} onChange={handleChange}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
Take a minute to understand the code above. If you're not familiar with generic types, it probably looks quite weird.
One thing you might be asking is why we had to define OptionValue
and then put extends OptionValue
in a bunch of places.
Well imagine we don't do that, and instead of Type extends OptionValue
we just put Type
instead. How would the Select
component know that the type Type
can either be a number
or a string
but nothing else?
It can't. That's why we have to say: "Hey, this Type
thing can either be a string or a number".
Wrap up
I hope this article helped you to better understand how generic types work. When you get to know them, they aren't so scary anymore 😊
Yes, the syntax can get some getting used to, and isn't very pretty. But generics are an important part of your TypeScript toolbox to create great TypeScript React applications, so don't shun them just for that.
Have fun building apps!
PS: Are there other generic type applications in React that I should mention in this article? If so, feel free to ping me on Twitter or shoot me an email at pierre@devtrium.com.