DEV Community

Sergey Egorov
Sergey Egorov

Posted on

Conditional TypeScript Generics in React Components

In my previous article here I promised to continue the discussion of TypeScript generics - a topic that I consider especially important when working with React components.

In that article we covered the basic definitions of generic components and looked at a simple example of when and why generics can be useful. In this post we will take the next step and focus on conditional generics and how they can be applied in real-world React components.

Here I would like to look at an example from my real project I worked on, which showed me just how elegant and powerful generic types can be.

Let's consider a selection component that allows us to select one or a few options depending on props.

First, let's look at the interfaces and types of the component without generics, treating them as a kind of component contract:

// Just an interface of option structure
interface Option {
  id: number;
  name: string;
}

// It's your value type
type Value = Option[] | Option | undefined;

// And component props interface
interface Props {
  value?: Value;
  options?: Option[];
  multiple?: boolean;
  onChange?: (value: Value) => void;
}
Enter fullscreen mode Exit fullscreen mode

I want you to pay attention to the fact that when using this component, you are not fully safe with value types, because TypeScript doesn't know whether the component is used in multiple selection mode or not. This happens because we allow our value to be either an array or a single object.

As a result, when using this component, value?: Value and onChange?: (value: Value) => void are always available in both states, independent of multiple?: boolean. This is not strict enough, and I would like to have a guarantee that if multiple is set to false, value must be a single object, and if it is true, value must be an array.

So, let me start implementing generics in our Props interface.

We still have the same option interface:

interface Option {
  id: number;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

Now, take a look at the enhanced Props interface with O and M types:

interface Props<
  O extends Option,
  M extends boolean | undefined = undefined
> {
  value?: M extends undefined | false ? O | undefined : O[];
  options?: O[];
  multiple?: M;
  onChange?: (
    values: M extends undefined | false ? O | undefined : O[]
  ) => void;
}
Enter fullscreen mode Exit fullscreen mode

In our Props interface we defined two new generic types:

1. O type extends Option - represents the option type
2. M type extends boolean | undefined — represents the multiple flag and defaults to undefined

Let's consider M as follows:

  • M is undefined if the multiple prop is not provided
  • M is false if the multiple prop is false
  • M is true if the multiple prop is true

3. value now has type
M extends undefined | false ? O | undefined : O[];

  • Value is O if the multiple flag is false
  • Value is O[] if the multiple flag is true

4. The same rule is also accepted for onChange callback function param values

To be honest, I do not like that we repeat this construction twice
M extends undefined | false ? O | undefined : O[];

I would prefer to create a separate generic type for it:

type Value<O, M> = M extends undefined | false ? O | undefined : O[];
Enter fullscreen mode Exit fullscreen mode

And then use it in our Props interface:

interface Props<O extends Option, M extends boolean | undefined = undefined> {
  value?: Value<O, M>;
  options?: O[];
  multiple?: M;
  onChange?: (values: Value<O, M>) => void;
}
Enter fullscreen mode Exit fullscreen mode

The final step is to extend the component itself with the generics, which is also very important.

export const SelectionExample = <
  O extends Option,
  M extends boolean | undefined = undefined
>({
  value = undefined,
  multiple = false,
  onChange = () => undefined,
  options = [],
}: Props<O, M>) => {
  const isOptionSelected = (option: O): boolean => {
    // Implementation of isOptionSelected here using value and multiple
    return false;
  };

  const handleOptionClick = (option: O) => {
    if (multiple) {
      // Implementation of handleOptionClick for multiple selection here using onChange
    } else {
      // Implementation of handleOptionClick for single selection here using onChange
    }
  };

  return (
    <div>
      {options.map((option) => {
        const selected = isOptionSelected(option);
        return (
          <Item
            key={option.id}
            selected={selected}
            option={option}
            onClick={handleOptionClick}
          />
        );
      })}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Please pay attention to the fact that in the component itself we also need to define the generic types and pass them into Props.

export const SelectionExample = <
  O extends Option,
  M extends boolean | undefined = undefined
>
...
}: Props<O, M>) => {
Enter fullscreen mode Exit fullscreen mode

The benefits of this approach are still not clear until we try to use this component. So, let's move on to that.

The first case is when we use the component with multiple selection so we expect TypeScript to allow us to pass only an array to the value property. At the same time, in the onChange callback, we can also expect to receive the selected array.

export const UsageExample = () => {
  return (
    <>
      <h1>Multiple selection</h1>
      <SelectionExample
        options={options}
        // multiple is switched on
        multiple={true}
        // value expects an array of options
        value={[options[0], options[1]]}
        onChange={(values) => {
          // values is the selected options array
          console.log(values);
        }}
      />
  );
};
Enter fullscreen mode Exit fullscreen mode

The single selection:

export const UsageExample = () => {
  return (
    <>
      <h1>Single selection</h1>
      <SelectionExample
        options={options}
        // multiple is switched off
        multiple={false}
        // value expects an option object
        value={options[0]}
        onChange={(value) => {
          // value is the selected option
          console.log(value);
        }}
      />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

And now let’s look at what happens if we try to use the component incorrectly:

export const UsageExample = () => {
  return (
    <>
      <h1>Wrong usage</h1>
      <SelectionExample
        options={options}
        // multiple is switched OFF now
        multiple={false}
        // value expects an object of Option
        value={[options[0], options[1]]} // TS ERROR: because multiple is switched off
        onChange={(values) => {
          // values has type Option | undefined
          console.log(values);
        }}
      />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

I know this article may have pushed you to go deepper into TypeScript features. However, I believe you would be satisfied once you finish your own similar implementation and realise how much more powerful your component has become.

Top comments (0)