DEV Community

Pierre Ouannes
Pierre Ouannes

Posted on • Updated on • Originally published at devtrium.com

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.

Top comments (12)

Collapse
 
michaeljota profile image
Michael De Abreu

Hey there.

I'm assuming you are learning TS and that's why you don't have a full understand of what generics are or how them work, and why they don't always work when using with React.

I just made a post to explain this a little better, because you have somethings that could be misinterpreted, and cause confusion. I hope you don't mind.

Collapse
 
pierreouannes profile image
Pierre Ouannes

Hey, thanks for engaging with my article.

If I've understood correctly your point you're saying that my section on arrow functions was overly broad and should be restricted to JSX, right?

Collapse
 
michaeljota profile image
Michael De Abreu

The syntax that you are using is incorrect, and can't be used. If you try to use it on Typescript Playground it will throw.

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.

Neither of them works. Nor on JSX syntax Link, nor on plain TS syntax Link.

And, your examples of non-working typing, the first one won't work, because you are trying to use generic declaration outside a function scope, but the latter can work without JSX syntax enabled Link

As I mention in the post, using generics in React components is complex, and it won't always work as you may expect. Even with the solution I provide in the example, it requires additional updates in the specific implementation. But, it's great to see that you are having interest in using generics, is a good approach over all.

Thread Thread
 
pierreouannes profile image
Pierre Ouannes

Ah yeah indeed I made a typo, in both of those cases the equal sign should be before the generyc type. I'll correct the article, thanks for pointing it out.

Thread Thread
 
michaeljota profile image
Michael De Abreu • Edited

Ok, it does work now if I move the generic declaration inside the function scope, but still, this doesn't allow me to create a generic functional component:

TS Playground

You can see that this should throw no error, but it does, because we can't use the generic as part of the component typing, because the component typing is outside the function and generic scope. So it does work, but it wouldn't any different than the function declaration component you have at the bottom, and both will have incomplete information about them being actual components. As far TS understand, they both can be used as components because they both recieve props, and return a JSX element, but they don't have all the properties of an actual components.

Thanks for sharing, that hack could be useful when dealing with hooks or hoc.

Collapse
 
spiritupbro profile image
spiritupbro

clear and concise love it sir

Collapse
 
pierreouannes profile image
Pierre Ouannes

Thanks!

Collapse
 
oaugusto256 profile image
Otavio Augusto

What a great article man! Thanks for do it and have shared with us!

Collapse
 
pierreouannes profile image
Pierre Ouannes

Thanks!

Collapse
 
mpriour profile image
Matt Priour

Wonderful article. Good explanations and examples.

Collapse
 
pierreouannes profile image
Pierre Ouannes

Hi Matt, I'm glad you liked it! Thanks

Collapse
 
pierreouannes profile image
Pierre Ouannes • Edited

Hey all

I'm very happy to be sharing with you my new article. If you have any question, feedback or suggestion on the article, please feel free to tell me!