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:
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
, theonChange
callback will receive a value of typestring
. - If we pass option values of type
number
, theonChange
callback will receive a value of typenumber
.
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 inferstarget
to be of typestring
. - When we call
useState
with an initial value of2019
, which is a number, TypeScript inferstarget
to be of typenumber
.
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 …
- the
string
type, - the
number
type, - any string literal type,
- any numeric literal type,
- the
never
type, and - any union of the above.
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 article and 44 others are part of the TypeScript Evolution series. Have a look!