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;
}
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;
}
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;
}
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:
-
Misundefinedif themultipleprop is not provided -
Misfalseif themultipleprop isfalse -
Mistrueif themultipleprop istrue
3. value now has type
M extends undefined | false ? O | undefined : O[];
- Value is
Oif themultipleflag isfalse - Value is
O[]if themultipleflag istrue
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[];
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;
}
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>
);
};
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>) => {
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);
}}
/>
);
};
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);
}}
/>
</>
);
};
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);
}}
/>
</>
);
};
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)