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!
Nick Scialli is a senior UI engineer at Microsoft.