Creating Typed Select/Combobox Components with Downshift and TypeScript Generics

The problem

Have you ever worked in a codebase where you have your select and combobox components take in items, onChange, value and other props only to return a generic string which you had to cast back to whatever value you expect, something like the following:

export type SelectItem = { 
  value: string;
  name: string;  
};

interface SelectProps {
  items: SelectItem[];
  onChange: (option: SelectItem) => void; 
  value?: string;  
  // Rest of your props
}

const Select = (props: SelectProps) => {
  // implementation of the component
}

And you used the select in the following example for a more specific country select:

type Country = "UK" | "US" | "AE";

const CountrySelect = () => {
  const countries: Country[] = ["UK, "US", "AE"" ];
  const [country, setCountry] = useState<Country>("UK"); 
  return (
    <Select 
      // Country returns a generic string!! We have to cast it back to
      // a country!!
      onChange={selected => setCountry(selected.value as Country)} 
      value={country} 
      items={[
        // Even though we pass in an array of countries it's not infered!
        { value: "UK", name: "United Kingdom" } 
      ]} />
  );
}

In the above example we have a simple implementation of a country select that uses a generic Select component to render a UI for the user to select a country, so what are the problems with the above approach?

  • onChange prop returns a generic string which has to be cast back to it's correct type!

  • Even though we pass in an array of strictly typed values the typing is lost and cast back to a generic string

  • What happens if we have numbers instead? We would have to cast them to strings, then cast them back into numbers again!!

So is there a way to improve this approach using typescript? Well, of course, otherwise I wouldn't be writing this article now, would I?

The solution

Let's start by improving the SelectItem with a generic type first!

// T generic provided to the select Item
export type SelectItem<T extends unknown> = {
  // Sets the type of value to T
  value: T;
  name: string; 
  // you can add stuff like subtitle, icons etc to the contract, up to you
};

Alright, so what did we do here exactly? Well, we tell typescript that the SelectItem will take in an unknown type value (so this can be a string, number, boolean...) and we expect that generic to be the value of the item.

So if you do something like:

// Contract is { value: Country, name: string }
type CountrySelectItem = SelectItem<Country>

You would get type-safe country items! Awesome, now what?

Well, now we define an interface for the actual Select component as to what it expects as Props:

// You can also extend a html element here to get html select props
// it's really up to you!
interface SelectProps<T extends unknown> {
  items: SelectItem<T>[];
  onChange: (option: SelectItem<T>) => void; 
  value?: T;  
  // Rest of your props
}

So what we are doing is we are telling Typescript, okay, our component will have some props, but we expect the following:

  • items prop to be an array of SelectItems with the generic T

  • onChange prop to accept the SelectItem generic as the first argument

  • value prop to be the generic type T

So what happens here with our Country type? Well, the following:

type CountrySelectProps = SelectProps<Country>
// the contract becomes the following:
{
  items: SelectItem<Country>[]; // [{ value: Country, name: string}]
  onChange: (option: SelectItem<Country>) => void // ({ value: Country, name: string }) => void
  value: Country
}

Alright, so we now have our SelectProps take in a generic type but we are still not passing a generic to them, let's fix that now:

export const Select = <T extends unknown>({
  items,
  onChange, 
  value,
  ...props
}: SelectProps<T>) => { /** rest of code */ }

Alright, so what does this do? Well we tell Typescript we expect a generic to be passed in as props and we don't know what it is, so Typescript is smart enough to know whenever you pass in a constrained instance of items or value to automatically infer the types, eg if you pass in an items array of value Country:

<Select items={[
  {value: "UK", name: "United Kingdom" },
  {value: "US", name: "US of A" }
]} />

Typescript will say:

  • Okay, item values are "UK" and "US" This means that the generic is either "US" or "UK"

  • The value of the select has to belong to the items array so that must mean that the value is either "UK" or "US"

  • Whenever we call the onChange it will get either the first or the second item as the value, which means the item.value will either be "UK" or "US"

And there it is! Our Selects and Comboboxes are fully type-safe! Hooray for us!

Now if you wish to add downshift on top of this I have prepared ready to go examples below, just run:

npm install downshift

Then follow the documentation provided on their site:

https://www.downshift-js.com/use-select

The idea is the same as above but with downshift for the juicy accessibility!

Full code implementation:

import { useSelect } from "downshift";

// T generic provided to the select Item
export type SelectItem<T extends unknown> = {
  // Sets the type of value to T
  value: T;
  name: string; 
  // you can add stuff like subtitle, icons etc to the contract, up to you
};

export const Select = <T extends unknown>({
  items,
  onChange, 
  value,
  ...props
}: SelectProps<T>) => {
  const select = useSelect({ 
    items,
    onSelectedItemChange: ({ selectedItem }) => {
      if (!selectedItem) {
        return;
      }
      onChange(selectedItem);
    },
  });

  return (
    <div>
      {/** Your select implementation here */}
    </div>
  );
 };

And if you want to do a ComboBox you can use the exact approach too:

import { useCombobox } from "downshift";
// We import the generic type from the select
import type { SelectItem } from "./select";

// You can also extend a html element here to get html select props
// it's really up to you!
interface ComboboxProps<T extends unknown> {
  items: SelectItem<T>[];
  onChange: (option: SelectItem<T>) => void; 
  value?: T;  
  // Rest of your props
}

export const Combobox = <T extends unknown>({
  items,
  onChange, 
  value,
  ...props
}: ComboboxProps<T>) => {
  const select = useCombobox({ 
    items,
    onSelectedItemChange: ({ selectedItem }) => {
      if (!selectedItem) {
        return;
      }
      onChange(selectedItem);
    },
  });

  return (
    <div>
      {/** Your combobox implementation here */}
    </div>
  );
 };

Thank you!

If you've reached the end you're a champ! I hope you liked my article.

If you wish to support me follow me on Twitter here:

https://twitter.com/AlemTuzlak59192

or if you want to follow my work you can do so on GitHub:

https://github.com/AlemTuzlak

And you can also sign up for the newsletter to get notified whenever I publish something new!