TypeOfNaN

How to Not Lose Typescript Types When Using Custom React Components

Nick Scialli
March 12, 2021

If we’re not careful, we can lose our Typescript types when implementing custom React components. This becomes especially problematic when implementing common components like form controls. In this post, we’ll take a common component (a radio group) in our app and fix its types to make sure any values and callbacks we provide it are correctly typed.

A Problematic Component: RadioGroup

Let’s say we have a common RadioGroup component in our application. We have a bunch of forms and don’t want to repeatedly create radio groups, so this makes a lot of sense. Our initial (problematic) implementation of the radio group is as follows:

type RadioGroupProps = {
  radios: { label: string; value: string }[];
  checked: string;
  setChecked: (value: string) => void;
};

const RadioGroup = ({ radios, checked, setChecked }: RadioGroupProps) => {
  return (
    <fieldset>
      {radios.map(({ label, value }) => (
        <label>
          <input
            type="radio"
            checked={value === checked}
            onChange={() => setChecked(value)}
          />
          {label}
        </label>
      ))}
    </fieldset>
  );
};

On the surface this seems fine, It will iterate through a list of radio options we provide it and show checked radio based on a prop we provide it. The problem, however becomes painfully clear when we try to implement it.

Take this implementation, for example, where we have our user select whether they have a car or motorcycle.

type Vehicle = 'car' | 'motorcycle';

const radios: { label: string; value: Vehicle }[] = [
  { label: 'Car', value: 'car' },
  { label: 'Motorcycle', value: 'motorcycle' },
];

export default function App() {
  const [checked, setChecked] = useState<Vehicle>('car');

  return (
    <div className="App">
      <RadioGroup
        radios={radios}
        //type error on setChecked!
        setChecked={setChecked}
        checked={checked}
      />
    </div>
  );
}

We find that we have a type error on setChecked! If we look at the compiler message, we see the following:

Type ‘Dispatch<SetStateAction>’ is not assignable to type ‘(value: string) => void’ Types of parameters ‘value’ and ‘value’ are incompatible. Type ‘string’ is not assignable to type ‘SetStateAction

So what’s going on here? Well it turns out we only told our common component RadioGroup that the radios array will contain values that are strings and, in turn, the setChecked function will be called with a string. This is a problem: the setChecked function we’re passing from our parent component is expecting a stricter type than a string: it’s expecting a Vehicle.

But how can our RadioGroup know the type of Vehicle when it has to also be able to take any other value types?

The Bad “Fix”: Type Assertion

One bad “fix” that I have seen is using a type assertion to basically tell Typescript that we know the type we’re passing setChecked is indeed a Vehicle. It would go like this:

export default function App() {
  const [checked, setChecked] = useState<Vehicle>('car');

  return (
    <div className="App">
      <RadioGroup
        radios={radios}
        setChecked={(value) => setChecked(value as Vehicle)}
        checked={checked}
      />
    </div>
  );
}

And that would work, but what’s really the point? While using type assertions can sometimes be necessary, we should really try to avoid it: telling the compiler that we know better than it is a recipe for runtime errors when we’re wrong!

So how do we fix this the right way?

The Good Fix: Generics!

Fortunately, this is the perfect use case for Typescript Generics! In the following code, we use a Generic T that extends string. When we pass our vehicle radios into the RadioGroup component, it’s now smart enough to know that the generic T represents the type of the radio values we’re passing. When we call setChecked on value, value is now type T, which is our vehicle case is indeed the Vehicle type.

type RadioGroupProps<T> = {
  radios: { label: string; value: T }[];
  checked: T;
  setChecked: (value: T) => void;
};

const RadioGroup = <T extends string>({
  radios,
  checked,
  setChecked,
}: RadioGroupProps<T>) => {
  return (
    <fieldset>
      {radios.map(({ label, value }) => (
        <label>
          <input
            type="radio"
            checked={value === checked}
            onChange={() => setChecked(value)}
          />
          {label}
        </label>
      ))}
    </fieldset>
  );
};

type Vehicle = 'car' | 'motorcycle';

const radios: { label: string; value: Vehicle }[] = [
  { label: 'Car', value: 'car' },
  { label: 'Motorcycle', value: 'motorcycle' },
];

export default function App() {
  const [checked, setChecked] = useState<Vehicle>('car');

  return (
    <div className="App">
      <RadioGroup radios={radios} setChecked={setChecked} checked={checked} />
    </div>
  );
}

And we’re good to go: our RadioGroup now will support any radio types we use!

If you'd like to support this blog by buying me a coffee I'd really appreciate it!

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli