Reputation: 171351
I'd like to have a CustomSelect
React component that works with any predefined set of options
and have TypeScript warn users when an unknown option is used.
Here is my attempt:
type Props<Value> = {
value: Value;
onChange: (newValue: Value) => void;
options: readonly string[];
};
function CustomSelect<Value>({
value,
onChange,
options
}: Props<Value>) {
return (
<select
value={value /* See Issue 1 below */}
onChange={(e) => {
onChange(e.target.value); // See Issue 2 below
}}
>
{options.map((value) => (
<option value={value} key={value}>
{value}
</option>
))}
</select>
);
}
Usage:
const FRUITS = ["apple", "banana", "melon"] as const;
type Fruit = typeof FRUITS[number];
<CustomSelect<Fruit> {/* See Bonus question below */}
value={state.fruit}
onChange={(newFruit) => { ... }}
options={FRUITS}
/>
This has two TypeScript issues:
Type 'Value' is not assignable to type 'string | number | readonly string[] | undefined'.
Argument of type 'string' is not assignable to parameter of type 'Value'.
Is it absolutely necessary to pass <Value>
to the CustomSelect
, or can CustomSelect
somehow deduct this Value
from the provided options
?
Upvotes: 5
Views: 18355
Reputation: 42198
<Value>
is a generic type parameter with no restrictions. But you pass it as the value
prop to the HTMLOptionElement
. This prop does have restrictions. It must be:
string | number | readonly string[] | undefined
So you have a few options:
Value
using the extends
keyword such that only valid option value types are allowed<Value extends string | number | readonly string[] | undefined>
<Value extends string | number>
<Value extends JSX.IntrinsicElements['option']['value']>
<Value extends NonNullable<JSX.IntrinsicElements['option']['value']>>
You can require that if the Value
type is not assignable to the <option>
then you must have an additional prop that maps the Value
to something you can handle. Technically we can use the array index as the value
but what we really need is the label
.
You can require that the options
prop be an array of objects with label
and value
. This is a common approach in third-party libraries. Both label
and value
should be string | number
but we can accept any additional properties on the option object such as data
.
This is an example approach to #2 above.
I am stealing from from @oieduardorabelo's answer to use e.target.selectedIndex
to get the index of the option as e.target.value
will always be string
.
Component
type Allowed = string | number;
type BaseProps<Value> = {
value: Value;
onChange: (newValue: Value) => void;
options: readonly Value[];
mapOptionToLabel?: (option: Value) => Allowed;
mapOptionToValue?: (option: Value) => Allowed;
};
// mappers required only in certain cirumstances
// we could get fancier here and also not require if `Value` has `value`/`label` properties
type Props<Value> = Value extends Allowed
? BaseProps<Value>
: Required<BaseProps<Value>>;
// type guard function checks value and refines type
const isAllowed = (v: any): v is Allowed =>
typeof v === "string" || typeof v === "number";
function CustomSelect<Value>({
value,
onChange,
options,
mapOptionToLabel,
mapOptionToValue
}: Props<Value>) {
const toLabel = (option: Value): Allowed => {
if (mapOptionToLabel) {
return mapOptionToLabel(option);
}
// if our props are provided correctly, this should never be false
return isAllowed(option) ? option : String(option);
};
const toValue = (option: Value): Allowed => {
if (mapOptionToValue) {
return mapOptionToValue(option);
}
return isAllowed(option) ? option : String(option);
};
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(options[e.target.selectedIndex]);
};
return (
<select value={toValue(value)} onChange={handleChange}>
{options.map((value) => (
<option value={toValue(value)} key={toValue(value)}>
{toLabel(value)}
</option>
))}
</select>
);
}
Usage
const FRUITS = ["apple", "banana", "melon"] as const;
type Fruit = typeof FRUITS[number];
const SelectFruit = () => {
const [selected, setSelected] = React.useState<Fruit>(FRUITS[0]);
return (
<div>
<div>Value: {selected}</div>
<CustomSelect value={selected} onChange={setSelected} options={FRUITS} />
</div>
);
};
const SelectNumber = () => {
const [n, setN] = React.useState(0);
return (
<div>
<div>Value: {n}</div>
<CustomSelect value={n} onChange={setN} options={[0, 1, 2, 3, 5]} />
</div>
);
};
interface User {
name: string;
id: number;
}
const SelectUser = () => {
const users: User[] = [
{
id: 1,
name: "John"
},
{
id: 322,
name: "Susan"
},
{
id: 57,
name: "Bill"
}
];
const [user, setUser] = React.useState(users[0]);
return (
<div>
<div>Value: {JSON.stringify(user)}</div>
<CustomSelect
value={user}
onChange={setUser}
options={users}
// has an error if no mapOptionToLabel is provided!
// I don't know why the type for user isn't automatic
mapOptionToLabel={(user: User) => user.name}
mapOptionToValue={(user: User) => user.id}
/>
</div>
);
};
Upvotes: 6
Reputation: 2985
the generic type for the HTML <option>
value is either string | number | readonly string[]
,
to make the type selection on onChange
of the select
to match your generic type, you need to select from the props.options
based on the selected index and not pass the value directly.
If you do pass the value directly, you will get an type error since e.target.value
is a string
and doesn't match the type Value
.
One way to achieve this is like the following:
type OptionValue = string | number;
type Props<Value extends OptionValue> = {
value: Value;
onChange: (newValue: Value) => void;
options: readonly Value[];
};
function CustomSelect<Value extends OptionValue>({
value,
onChange,
options,
}: Props<Value>) {
return (
<select
value={value}
onChange={(event: React.FormEvent<HTMLSelectElement>) => {
const selectedOption = options[event.currentTarget.selectedIndex];
onChange(selectedOption);
}}
>
{options.map((value) => (
<option value={value} key={value}>
{value}
</option>
))}
</select>
);
}
function Form() {
const fruits = ["apple", "banana", "melon"] as const;
const [fruit, setFruits] = React.useState<typeof fruits[number]>("apple");
return <CustomSelect value={fruit} onChange={setFruits} options={fruits} />;
}
It is heavily based on the walk through:
from the "TypeScript Evolution" series
Upvotes: 1
Reputation: 442
I think below is the best I can do.
Since e.target.value
is basically string
type, so i had to convert it through as
.
type Props<Value> = {
value: Value;
onChange: (newValue: Value) => void;
options: readonly string[];
};
export default function CustomSelect<T extends string | number | readonly string[]>({
value,
onChange,
options
}: Props<T>) {
return (
<select
value={value}
onChange={(e) => {
onChange(e.target.value as T);
}}
>
{options.map((value) => (
<option value={value} key={value}>
{value}
</option>
))}
</select>
);
}
Upvotes: 0