reactjstypescriptreact-typescriptreact-aria

How can I correctly set the type of a value in the onChange method in React with TypeScript?


I use React Aria Components' RadioGroup with controlled values to display an array of options. The options are typed with an enum. To store the selected option locally, I use a useState with the first option as a default value.

However I get Argument of type 'string' is not assignable to parameter of type 'SetStateAction<Option>'. with this code:

import { useState } from "react";
import { Label, Radio, RadioGroup } from "react-aria-components";

enum Option {
  ONE = "option_one",
  TWO = "option_two",
}

export default function App() {
  const options: Option[] = [Option.ONE, Option.TWO];
  const [selectedOption, setSelectedOption] = useState(options[0]);

  return (
    <RadioGroup
      onChange={(value) => setSelectedOption(value)}
      value={selectedOption}
    >
      <Label>Options</Label>
      {options.map((option) => (
        <Radio key={option} value={option}>
          {option}
        </Radio>
      ))}
    </RadioGroup>
  );
}

The useState id correctly inferred as Option, but the value is considered a string.

Minimum reproduction sandbox

The only thing working so far seems to be a type assertion like:

export default function App() {
  // ...

  return (
    <RadioGroup
      onChange={(value) => setSelectedOption(value as Option)}
      value={selectedOption}
    >
      // ...
    </RadioGroup>
  );
}

I generally try to avoid type assertions. Is there another smoother way to get the right type for the onChange event?


Solution

  • Since you're starting with a string (value), you have to tell TypeScript you know it's a valid Option. There are at least two ways to do this:

    1. Use a type predicate or an assertion function.

    2. Use a type assertion.

    I prefer using a type predicate or assertion function since it not only satisfies TypeScript, but also checks things at runtime so that if I make a mistake (for instance: adding a hardcoded "none" option to the list that isn't in Option), I get a clear error I can fix. Here you probably want an assertion function, since there isn't a valid code path where the value wouldn't be correct:

    function assertIsOption(value: string): asserts value is Option {
        if (!Object.keys(Option).includes(value)) {
            throw new Error(`Invalid Option value: "${value}"`);
        }
    }
    

    (There are various ways to spin that; the point is just that it's an assertion function that checks value.)

    Then use it in your click handler:

    <RadioGroup onChange={(value) => {
        assertIsOption(value);
        setSelectedOption(value);
    }} value={selectedOption}>
    

    Playground example (note: it takes a long time for the playground to finish loading the libs).