Conditional TypeScript Generics in React Components
Source: Dev.to
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 a real project I worked on, which showed me just how elegant and powerful generic types can be.
The problem: a selection component without generics
Let’s consider a selection component that allows us to select one or a few options depending on props.
// Just an interface of option structure
interface Option {
id: number;
name: string;
}
// It's your value type
type Value = Option[] | Option | undefined;
// Component props interface
interface Props {
value?: Value;
options?: Option[];
multiple?: boolean;
onChange?: (value: Value) => void;
}
When using this component you are not fully safe with the value type, because TypeScript doesn’t know whether the component is used in multiple‑selection mode or not.
value?: Value and onChange?: (value: Value) => void are always available in both states, independent of multiple?: boolean. This is not strict enough – we would like a guarantee that:
- if
multipleisfalse(or omitted) →valuemust be a single object (orundefined); - if
multipleistrue→valuemust be an array.
Introducing generics to the Props interface
We keep the same Option interface:
interface Option {
id: number;
name: string;
}
Now we enhance Props with two generic parameters, O and M:
interface Props<O extends Option = 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;
}
Explanation
| Generic | Meaning |
|---|---|
O | Extends Option; represents the option type. |
M | Extends `boolean |
value | M extends undefined | false ? O | undefined : O[] – a single O (or undefined) when multiple is false/omitted, otherwise an array O[]. |
onChange | Same conditional type for the callback argument. |
Extracting the conditional type
Repeating the conditional expression is noisy, so we extract it into its own generic type:
type Value<O extends Option, M extends boolean | undefined> =
M extends undefined | false ? O | undefined : O[];
Now Props becomes much cleaner:
interface Props<O extends Option = Option, M extends boolean | undefined = undefined> {
value?: Value<O, M>;
options?: O[];
multiple?: M;
onChange?: (values: Value<O, M>) => void;
}
Adding generics to the component itself
The component must also be generic and pass those parameters to Props:
export const SelectionExample = <
O extends Option = Option,
M extends boolean | undefined = undefined
>({
value = undefined,
multiple = false,
onChange = () => undefined,
options = [],
}: Props<O, M>) => {
const isOptionSelected = (option: O): boolean => {
// Implementation using `value` and `multiple`
return false;
};
const handleOptionClick = (option: O) => {
if (multiple) {
// Implementation for multiple selection using `onChange`
} else {
// Implementation for single selection using `onChange`
}
};
return (
<div>
{options.map((option) => {
const selected = isOptionSelected(option);
return (
<div key={option.id}>
{/* Render option UI */}
</div>
);
})}
</div>
);
};
Note: The generic parameters
<O, M>are declared on the component and then supplied toProps.
Using the generic component
1️⃣ Multiple selection
export const UsageExample = () => {
const options: Option[] = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
return (
<SelectionExample
multiple
options={options}
onChange={(selected) => {
// `selected` is inferred as `Option[]`
console.log("selected:", selected);
}}
/>
);
};
2️⃣ Single selection
export const SingleUsage = () => {
const options: Option[] = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
return (
<SelectionExample
options={options}
onChange={(selected) => {
// `selected` is inferred as `Option | undefined`
console.log("selected:", selected);
}}
/>
);
};
In both cases TypeScript correctly infers the shape of value and the argument type of onChange, giving us the strict guarantees we wanted.
Takeaways
- Conditional generics let us express relationships between props (e.g.,
multiple↔valueshape). - Extracting repeated conditional logic into a reusable generic type (
Value) keeps the code DRY and readable. - Declaring generics on the component itself and forwarding them to the props interface ensures the whole component is type‑safe.
With this pattern you can build highly reusable, type‑safe React components that adapt their API based on the values of their props. Happy typing!
Multiple selection (additional example)
export const UsageExample = () => {
return (
<>
{/* ... */}
{/* `values` is the selected options array */}
{console.log(values)}
</>
);
};
Single selection (additional example)
export const UsageExample = () => {
return (
<>
{/* ... */}
{/* `value` is the selected option */}
{console.log(value)}
</>
);
};
Wrong usage
export const UsageExample = () => {
return (
<>
{/* ... */}
{/* `values` has type `Option | undefined` – this is a misuse when multiple is expected */}
{console.log(values)}
</>
);
};
I know this article may have pushed you to go deeper into TypeScript features. However, I believe you’ll feel satisfied once you finish your own similar implementation and realize how much more powerful your component has become.