React & TypeScript: use generics to improve your types

pierreouannes

Pierre Ouannes

Posted on October 5, 2021

React & TypeScript: use generics to improve your types

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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?

Fair warning: this section is a bit more complex than the others in this article. Feel free to revisit it later if you don't get it at first.

Let's have a look at the type definition for our beloved hook useState:

function useState<S>(
  initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
Enter fullscreen mode Exit fullscreen mode

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 called S.
  • 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 is S.
  • 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 type SetStateAction<S> is applied. SetStateAction<S> itself is the SetStateAction type with the generic type S applied (it's our state setter).

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

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

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

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

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

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.

If you want to learn how to type all hooks in React, stay tuned! An article on that subject will be out next week. Subscribe to be sure to see it!

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

If you're unsure about what's going on with the type of the event object in handleChange, I have an article explaining how to use TypeScript with events in React

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

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 🍉' },
];
Enter fullscreen mode Exit fullscreen mode

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

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".

It's a detail unrelated to generics, but if you use the above code in an actual editor you'll probably get a TypeScript error inside the handleChange function.

The reason for that is that event.target.value will be converted to a string, even if it was a number. And useState expects the type Type, which can be a number. So there's an issue there.

The best way I've found to handle this is by using the index of the selected element instead, like so:

function handleChange(event: ChangeEvent<HTMLSelectElement>) {
  setValue(options[event.target.selectedIndex].value);
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
pierreouannes
Pierre Ouannes

Posted on October 5, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related