skip to Main Content

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


  1. Does using a type guard work for you?

    function isBook(obj: unknown): obj is Book {
       return (obj as Book)?.author !== undefined
    }
    

    You would use it in your code like so:

    <GenericSelect
    onChange={(value) => {
      if (isBook(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}
    />
    
    Login or Signup to reply.
  2. The problem with

    type GenericSelectProps = {
        values: (Book | Movie)[];
        onChange: (value: Book | Movie) => void;
    };
    

    is that onChange() simply must handle Book arguments as well as Movie arguments. Its type says that callers can choose to pass in a Book or a Movie, at their own discretion. Additionally, values can be an array of Book objects, or an array of Movie objects, or a heterogeneous array where some elements are Books and some elements are Movies. You haven’t expressed your intent that the type of the values elements might be narrower than Book | Movie and that onChange is allowed to expect the same narrower type.


    You can try to rewrite this as a union type:

    type BookSelectProps = {
        values: Book[];
        onChange: (value: Book) => void;
    }
    
    type MovieSelectProps = {
        values: Movie[];
        onChange: (value: Movie) => void;
    };
    
    type GenericSelectProps = BookSelectProps | MovieSelectProps
    

    So now, you can at least be sure that values and onChange are correlated. But unfortunately since GenericSelectProps isn’t a discriminated union, TypeScript doesn’t automatically narrow onChange‘s expected type based on the type of values:

    <GenericSelect
        onChange={(value) => console.log(value.author)} // error!
        //         ~~~~~
        // Parameter 'value' implicitly has an 'any' type.
        values={books}
    />
    

    That is, contextual typing of the value callback parameter fails. You can work around that by annotating the value parameter

    <GenericSelect
        onChange={(value: Book) => console.log(value.author)} // okay
        values={books}
    />
    

    but 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 an X or with a Y, 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:

    export const GenericSelect: {
        (props: BookSelectProps): JSX.Element;
        (props: MovieSelectProps): JSX.Element;
    } = ({ values, onChange }: BookSelectProps | MovieSelectProps) => { ⋯ };
    

    The function implementation is the same, but now there are two call signatures. You can see that this behaves as desired:

    <GenericSelect
        onChange={(value) => console.log(value.author)} // okay
        values={books}
    />
    

    A different approach which scales better for more types is to change from unions/overloads to generics:

    type GenericSelectProps<T> = {
        values: T[];
        onChange: (value: T) => void;
    };
    
    export const GenericSelect = <T extends Book | Movie>(
      { values, onChange }: GenericSelectProps<T>) => { ⋯ };
    

    For a GenericSelectProps<T>, it is easy to see that values always holds an array of things that onChange() expects. This can be narrower than Book | Movie, and if you want to add more elements to that union like Book | Movie | Podcast | NurseryRhyme you can, and it doesn’t change things.

    When you call GenericSelect, TypeScript will infer the type of value contextually from the type of the elements of values. And if value is an array of some Books and some Movies, then value will be Book | Movie and you’ll have to implement it by checking value.

    <GenericSelect
        onChange={(value) => console.log(value.author)}
        values={books}
    />
    

    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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search