Misha Moroshko
Misha Moroshko

Reputation: 171351

How to type a custom React select component using TypeScript?

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:

Issue 1

Type 'Value' is not assignable to type 'string | number | readonly string[] | undefined'.

Issue 2

Argument of type 'string' is not assignable to parameter of type 'Value'.

Bonus question

Is it absolutely necessary to pass <Value> to the CustomSelect, or can CustomSelect somehow deduct this Value from the provided options?

CodeSandbox Playground

Upvotes: 5

Views: 18355

Answers (3)

Linda Paiste
Linda Paiste

Reputation: 42198

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:

  1. 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']>>
  1. 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.

  2. 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

Upvotes: 6

oieduardorabelo
oieduardorabelo

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

Won Gyo Seo
Won Gyo Seo

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

Related Questions