Passing Generics to JSX Elements in TypeScript

TypeScript 2.9 added the ability to specify type arguments for generic JSX elements. This means we can now write the following component in a TSX file:

function Form() {
  // ...
  
  return (
    <Select<string>
      options={targets}
      value={target}
      onChange={setTarget}
    />
  );
}

To understand why it's useful to have generic JSX elements (and why we typically don't have to write out the type argument explicitly), let's create the above Select component and iterate on its static types. Here we go!

Step #1: Implementing Select in JavaScript/JSX

Let's go ahead and implement a reusable Select component in React. Our component should render a native <select> element with a bunch of <option> children:

Native select control with 7 options in the browser

We want to pass the options as props to the Select component, as well as the currently selected value and an onChange callback. Here's the code for the component shown in the above screenshot:

function Form() {
  const targets = [
    { value: "es3", label: "ECMAScript 3" },
    { value: "es5", label: "ECMAScript 5" },
    { value: "es2015", label: "ECMAScript 2015" },
    { value: "es2016", label: "ECMAScript 2016" },
    { value: "es2017", label: "ECMAScript 2017" },
    { value: "es2018", label: "ECMAScript 2018" },
    { value: "es2019", label: "ECMAScript 2019" }
  ];

  const [target, setTarget] = useState("es2019");

  return (
    <Select
      options={targets}
      value={target}
      onChange={setTarget}
    />
  );
}

How would we implement the Select component in plain JavaScript and JSX? Here's a first attempt:

function Select(props) {
  function handleOnChange(e) {
    props.onChange(e.currentTarget.value);
  }
  return (
    <select value={props.value} onChange={handleOnChange}>
      {props.options.map(option => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

Our component accepts props and returns a <select> element that contains all options as specified by the options prop. We also define a function handleOnChange which is invoked whenever the selected value changes; it calls the onChange callback with the selected value.

This component works as expected! Let's now statically type it in TypeScript and TSX.

Step #2: Implementing Select in TypeScript/TSX

We'll start by creating a type that models a single option. Let's call it Option and define two properties, one for the underlying value and one for the label that we want to display:

type Option = {
  value: string;
  label: string;
};

That was easy enough. Next, let's move on to specifying a type for the props of the Select component. We need an options prop that uses the Option type we just created, a value prop for the currently selected value, and an onChange callback that is invoked whenever the selected value changes:

type Props = {
  options: Option[];
  value: string;
  onChange: (value: string) => void;
};

Finally, let's put the Props to use and add a type annotation to the parameter e of our handleOnChange function:

function Select(props: Props) {
  function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
    props.onChange(e.currentTarget.value);
  }
  return (
    <select value={props.value} onChange={handleOnChange}>
      {props.options.map(option => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

We now have a fully statically typed React component. It currently requires all options to specify a value of type string, a constraint that might be too limiting in a real-world application. (Or it might not be! In that case, we could stop right here.)

Step #3: Supporting Numeric Option Values

Whilst using string values is a common use, it is certainly not the only one! We might want the Select component to accept numeric option values as well:

function Form() {
  const targets = [
    { value: 3, label: "ECMAScript 3" },
    { value: 5, label: "ECMAScript 5" },
    { value: 2015, label: "ECMAScript 2015" },
    { value: 2016, label: "ECMAScript 2016" },
    { value: 2017, label: "ECMAScript 2017" },
    { value: 2018, label: "ECMAScript 2018" },
    { value: 2019, label: "ECMAScript 2019" }
  ];

  const [target, setTarget] = useState(2019);

  return (
    <Select
      options={targets}
      value={target}
      onChange={setTarget}
    />
  );
}

Note that I've replaced the string values by numeric ones, including the initial value passed to the useState Hook.

Before we update the types for our Select component, let's add support for non-string option values to our handleOnChange function. Currently, it only works correctly if we're dealing with string values. e.currentTarget.value is always a string, even if we specify numeric values for our options.

Luckily, the fix is quite short. Instead of reading e.currentTarget.value and passing it to the onChange callback directly, we can obtain the index of the selected option via the e.currentTarget.selectedIndex property. We can then grab the option in our options array at that index and invoke onChange with its value:

function Select(props: Props) {
  function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
    const { selectedIndex } = e.currentTarget;
    const selectedOption = props.options[selectedIndex];
    props.onChange(selectedOption.value);
  }
  return (
    <select value={props.value} onChange={handleOnChange}>
      {props.options.map(option => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

This approach works because we're rendering a single <option> element for each item in the options array, preserving their order and not adding additional <option> elements.

Now that we've fixed the implementation of our Select component, let's fix its types. We currently get a type error because we're passing target (which is inferred to be of type number) as the value prop (which is expected to be of type string).

Let's change the type of the value property from string to string | number to support numeric values as well:

type OptionValue = string | number;

type Option = {
  value: OptionValue;
  label: string;
};

type Props = {
  options: Option[];
  value: OptionValue;
  onChange: (value: OptionValue) => void;
};

Notice that I've introduced a type alias called OptionValue so that we don't have to repeat the union type string | number in multiple places.

Unfortunately, our Props type isn't quite right yet. Our option values are now typed as string | number, but that also means that our onChange callback receives a value of type string | number. This type doesn't model the behavior of the Select component correctly:

  • If we pass option values of type string, the onChange callback will receive a value of type string.
  • If we pass option values of type number, the onChange callback will receive a value of type number.

In other words, we're losing type information along the way. This is problematic when we want to use the parameter, e.g. when we want to call the setTarget function returned by our useState Hook:

  • When we call useState with an initial value of "es2019", which is a string, TypeScript infers target to be of type string.
  • When we call useState with an initial value of 2019, which is a number, TypeScript infers target to be of type number.

Either way, a value of type string | number is neither assignable to string nor to number. TypeScript will therefore issue a type error for the onChange prop of our Select element:

Type 'number' is not assignable to type 'SetStateAction'.

So how do we properly type our React component? The answer is generics.

Step 4: Using Generics for Precise Prop Types

Instead of using the type string | number everywhere, let's use a generic type T for the values of our options. We'll make our Options type generic by adding a type parameter list. Then we'll use the type T for the value property:

type OptionValue = string | number;

type Option<T extends OptionValue> = {
  value: T;
  label: string;
};

Notice that we've constrained the type parameter T to extend our OptionValue type. In other words, we can specify any type for the generic type T that is assignable to string | number. This includes …

Now that the Option type is generic, we have to specify a type argument when using it for the options prop within our Props type. This, in turn, means that we should make Props generic as well. Again, we'll introduce a generic type parameter T and use it for the value and onChange props:

type Props<T extends OptionValue> = {
  options: Option<T>[];
  value: T;
  onChange: (value: T) => void;
};

Now that Props is a generic type, we have to provide a type argument for the type parameter T when using Props within our Select component. We also have to repeat the extends OptionValue constraint so that we can pass T to Props<T> — it's turtles all the way down:

function Select<T extends OptionValue>(props: Props<T>) {
  function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
    const { selectedIndex } = e.currentTarget;
    const selectedOption = props.options[selectedIndex];
    props.onChange(selectedOption.value);
  }
  return (
    <select value={props.value} onChange={handleOnChange}>
      {props.options.map(option => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

We've successfully made Select a generic function component. itself. Now, here's where TypeScript 2.9 comes into play! We can specify a generic type when creating the <Select> JSX element:

function Form() {
  const targets = [
    { value: "es3", label: "ECMAScript 3" },
    { value: "es5", label: "ECMAScript 5" },
    { value: "es2015", label: "ECMAScript 2015" },
    { value: "es2016", label: "ECMAScript 2016" },
    { value: "es2017", label: "ECMAScript 2017" },
    { value: "es2018", label: "ECMAScript 2018" },
    { value: "es2019", label: "ECMAScript 2019" }
  ];

  const [target, setTarget] = useState("es2019");

  return (
    <Select<string>
      options={targets}
      value={target}
      onChange={setTarget}
    />
  );
}

Granted, the syntax looks a bit odd at first. However, on second thought, it's consistent with how we specify generic arguments in other places in TypeScript.

Now that we've made the Select component and both the Props and Option types generic, our program type-checks just fine — no more type errors, no matter whether we use strings, numbers, or both for our option values.

Notice that we don't have to specify the generic type argument in the JSX element explicitly here. TypeScript can infer it for us! By looking at the type of the value properties of the objects in our targets array, TypeScript understands that we're using values of type string in this example.

Because TypeScript can contextually infer the type string for us, we can change <Select<string> back to just <Select. Here's the full working example:

type OptionValue = string | number;

type Option<T extends OptionValue> = {
  value: T;
  label: string;
};

type Props<T extends OptionValue> = {
  options: Option<T>[];
  value: T;
  onChange: (value: T) => void;
};

function Select<T extends OptionValue>(props: Props<T>) {
  function handleOnChange(e: React.FormEvent<HTMLSelectElement>) {
    const { selectedIndex } = e.currentTarget;
    const selectedOption = props.options[selectedIndex];
    props.onChange(selectedOption.value);
  }
  return (
    <select value={props.value} onChange={handleOnChange}>
      {props.options.map(option => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

function Form() {
  const targets = [
    { value: "es3", label: "ECMAScript 3" },
    { value: "es5", label: "ECMAScript 5" },
    { value: "es2015", label: "ECMAScript 2015" },
    { value: "es2016", label: "ECMAScript 2016" },
    { value: "es2017", label: "ECMAScript 2017" },
    { value: "es2018", label: "ECMAScript 2018" },
    { value: "es2019", label: "ECMAScript 2019" }
  ];

  const [target, setTarget] = useState("es2019");

  return (
    <Select
      options={targets}
      value={target}
      onChange={setTarget}
    />
  );
}

And there you go! A statically typed Select component in React, making use of generic type arguments for JSX elements.

This post is part of the TypeScript Evolution series.