Value can be Anything
<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:
- You can limit the acceptable types for
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.
Mapping Value
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>
);
};
Code Sandbox Link
Valuetype?Valueis whatever is passed toCustomSelect, e.g.<CustomSelect<Fruit>above.onChangeand others that you define based onvalueValuewhich are not valid to be passed to the DOM, such as objects, then you need to get a bit trickier than the current answers. You can use the array index as thevalueprop for the HTML<option/>. You'll want some sort ofmapValueToLabelprop which is optional if theValueis a valid label type but required otherwise. I can def write those typings into an answer but I don't know if I'm overthinking this or thinking the right amount :)Selectcomponent. kentcdodds.com/blog/compound-components-with-react-hooks