I was creating Select component in React with Typescript. Here is the code:
type GenericSelectProps = {
values: (Book | Movie)[];
onChange: (value: Book | Movie) => void;
};
export const GenericSelect = ({ values, onChange }: GenericSelectProps) => {
const onSelectChange = (e) => {
const val = values.find((value) => value.id === e.target.value);
if (val) onChange(val);
};
return (
<select onChange={onSelectChange}>
{values.map((value) => (
<option key={value.id} value={value.id}>
{value.title}
</option>
))}
</select>
);
};
export type Book = {
id: string;
title: string;
author: string;
};
export type Movie = {
id: string;
title: string;
releaseDate: string;
};
const books: Book[] = [
{
id: "1",
title: "Good omens",
author: "Terry Pratchett & Neil Gaiman"
},
{
id: "2",
title: "The Truth",
author: "Terry Pratchett"
}
];
When I try using the component like this:
<GenericSelect
onChange={(value) => console.log(value.author)}
values={books}
/>
Then typescript shows error because it gets confused with value, that is, whether it is Movie or Book. But, if we use type narrowing then not working anyways.
<GenericSelect
onChange={(value) => {
if ("author" in value) {
console.log(value.author); // Only accessible if `value` is a `Book`
} else {
console.log(value.title); // Only accessible if `value` is a `Movie`
}
}}
values={books}
/>
2
Answers
Does using a type guard work for you?
You would use it in your code like so:
The problem with
is that
onChange()
simply must handleBook
arguments as well asMovie
arguments. Its type says that callers can choose to pass in aBook
or aMovie
, at their own discretion. Additionally,values
can be an array ofBook
objects, or an array ofMovie
objects, or a heterogeneous array where some elements areBook
s and some elements areMovie
s. You haven’t expressed your intent that the type of thevalues
elements might be narrower thanBook | Movie
and thatonChange
is allowed to expect the same narrower type.You can try to rewrite this as a union type:
So now, you can at least be sure that
values
andonChange
are correlated. But unfortunately sinceGenericSelectProps
isn’t a discriminated union, TypeScript doesn’t automatically narrowonChange
‘s expected type based on the type ofvalues
:That is, contextual typing of the
value
callback parameter fails. You can work around that by annotating thevalue
parameterbut I suspect you prefer inference there.
A similar approach is to replace the union with a pair of overloads. If you have a function of the form
{(a: X | Y): Z}
, and you intend it to be called either with anX
or with aY
, then you can rewrite it to be something like{(a: X): Z; (a: Y): Z}
, and it will behave similarly. Instead of a single call signature that accepts two different things, it’s a pair of call signatures, each of which accepts one:The function implementation is the same, but now there are two call signatures. You can see that this behaves as desired:
A different approach which scales better for more types is to change from unions/overloads to generics:
For a
GenericSelectProps<T>
, it is easy to see thatvalues
always holds an array of things thatonChange()
expects. This can be narrower thanBook | Movie
, and if you want to add more elements to that union likeBook | Movie | Podcast | NurseryRhyme
you can, and it doesn’t change things.When you call
GenericSelect
, TypeScript will infer the type ofvalue
contextually from the type of the elements ofvalues
. And ifvalue
is an array of someBook
s and someMovie
s, thenvalue
will beBook | Movie
and you’ll have to implement it by checkingvalue
.Either the overload or the generic solutions should work; personally I would prefer generics here, since they tend to scale better, but that’s out of scope for the question as asked.
Playground link to code