A prop which should only be set when another prop has a specific value.
Relationships between React component props can make you feel the pinch. This article will be your road-map to conditional props pattern employed using Typescript. I will propose different situations and demonstrate the answers to these questions:
How can we create a dependent relationship between several props using TypeScript?
What can we do to have it generate TypeScript errors when a relationship is broken?
Conflicting properties
Working on a design system, I had to create an avatar component. To pass props to the avatar component, different conditions were present:
- If i pass the
icon
prop i can't pass thesrc
prop - If i pass the
src
prop i can't pass theicon
prop
Here an example for the simple avatar component without the conditions
type AvatarProps = {
icon?: JSX.Element;
src?: string;
children:React.ReactNode;
};
export const Avatar = (props: AvatarProps): JSX.Element => {
const { icon, src } = props;
return (
<div>
{icon && icon}
{JSON.stringify(src)}
{children}
</div>
);
};
If we import the component while passing both props, the component will not raise any errors.
Therefore, we have to provide an indication for the developer to tell them that passing the two in the same time is forbidden by just throwing a typescript error.
To achieve that , we can create union type using two types that reflect the two scenarios our component supports:
interface CommonProps {
children?: React.ReactNode
// ...other props that always exist
}
type ConditionalProps =
| {
icon?: JSX.Element;
src?: never;
}
| {
icon?: never;
src?: string;
};
type Props = CommonProps & ConditionalProps
export const Avatar = (props: Props): JSX.Element => {
const { icon, src } = props;
return (
<div>
{icon && icon}
{JSON.stringify(src)}
{children}
</div>
);
};
For those of you who are already familiar with TypeScript, that should be sufficient information
However, in just a few lines of code, there is a lot going on. Let's break it down into chunks if you're wondering about what it all means and how it all works.
interface CommonProps {
children: React.ReactNode
// ...other props that always exist
}
CommonProps
is your typical props definition in TypeScript. It’s for all of the “Common” props that figure in all scenarios and that aren’t dependent on other props. In addition to children,
there might be shadow
, size
, shape
, etc.
type ConditionalProps =
// If i pass the icon prop i can't pass the src prop
| {
icon?: JSX.Element;
src?: never;
}
// If i pass the src prop i can't pass the icon prop
| {
src?: string;
icon?: never;
};
ConditionalProps
is where the magic happens. It’s what’s called a ”discriminated union.” It’s union of object definitions.
Let’s break it down further and we’ll come back to see how the discriminated union works for us.
{
icon?: JSX.Element;
src?: never;
}
The first part of the discriminated union is when the icon
prop is defined, In this case, we want the src
prop to be invalid. It shouldn’t be able to be set.
{
icon?: never;
src?: string;
};
The second part is when the icon
prop is unspecified (undefined
). Then we can pass the src props with no problems
type ConditionalProps =
| {
icon?: JSX.Element;
src?: never;
}
| {
icon?: never;
src?: string;
};
So now back to the entire discriminated union. It’s saying that the configuration for the icon
and src
props can either be the first case or the second case.
It's worth noting that we've used the keyword never in this example. The best explanation of this keyword can be found in the TypeScript documentation:
“TypeScript will use a never type to represent a state which shouldn’t exist.”
To reiterate, we defined two types for two scenarios and combined them using the union operator.
type Props = CommonProps & ConditionalProps
Props
becomes the intersection of CommonProps
and ConditionalProps
.
Props
is the combination of the two types. So it’ll have all the properties from CommonProps
as well as this dependent relationship we created with ConditionalProps
.
Now finally, in the Avatar
component, both the icon
and src
props will be of there type respectively JSX.Element | undefined
and string | undefined
So their types come out straightforward as if you hadn’t created the dependent relationship.
Now if we try to provide both props, we will see a TypeScript error:
Conditional prop variation
I needed to create a component with different variants, for each variant we have a set of props .
We want those props to be provided only when a matching variant is selected.
in our case we have 3 variants "text" | "number" | "element"
- If the we chose to set the
variant
totext
, we need to have amessage
prop of typestring
, and we can't setcomponentName
prop - If the we chose to set the
variant
tonumber
, we need to have amessage
props of typenumber
, and we can't setcomponentName
prop - If we do pass the
variant
aselement
, here we can use finallycomponentName
also themessage
prop will become of typeJSX.Element
Lets take a look at this example
interface CommonProps {
children?: React.ReactNode;
// ...other props that always exist
}
type ConditionalProps =
| {
componentName?: string;
message?: JSX.Element;
variant?: "element";
}
| {
componentName?: never;
message?: string;
variant?: "text";
}
| {
componentName?: never;
message?: number;
variant?: "number";
};
type Props = CommonProps & ConditionalProps;
export const VariantComponent = (props: Props): JSX.Element => {
const { message, componentName, variant = "element", children } = props;
return (
<div>
{message && message}
{variant === "element" && componentName}
{children}
</div>
);
};
/*
* If the we chose to set the variant to text,
* we need to have a message props of type string,
* We can't set componentName prop
*/
{
componentName?: never;
message?: string;
variant?: "text";
}
/*
* If the we chose to set the variant to number,
* we need to have a message props of type number,
* and we can't set componentName prop
*/
{
componentName?: never;
message?: number;
variant?: "number";
}
/*
* If we do pass the variant as element,
* here we can use finally componentName
* also the message prop will become of type JSX.Element
*/
{
componentName: string;
message?: JSX.Element;
variant?: "element";
}
Once we set the variant
prop , TypeScript narrows down component’s type to their respective desired properties and tells you what you need to provide
Conditional props for collection with generic type
For our next use case, let’s try defining conditional props for a Select component. Our component needs to be flexible enough to accept an array of strings or objects for its options
property.
If the component receives an array of objects, we want the developer to specify which fields of those objects we should use as a label and value.\
Conditional types for collection property
type SelectProps<T> =
| {
options: Array<string>;
labelProp?: never;
valueProp?: never;
}
| {
options: Array<T>;
labelProp: keyof T;
valueProp: keyof T;
};
export const Select = <T extends unknown>(props: SelectProps<T>) => {
return <div>{JSON.stringify(props)}</div>;
};
To match the object that the user provide to the select. we can use generics in TypeScript.
{
options: Array<T>;
labelProp: keyof T;
valueProp: keyof T;
}
In our second type, we change the options
prop from Array<Object>
to Array<T>
for our generic object. The client has to provide an array of items of the generic object type.
We're using the keyof keyword to tell TypeScript that we're expecting labelProp
and valueProp
to be generic object fields.
Now when you try to provide valueProp
or labelProp
, you’ll see a nice autocomplete suggestion based on the fields of the options items.
However, there is a minor change that we must make in order to avoid certain issues. We want to make sure that the generic object we've been given is a custom object rather than a primitive, such as a string:
type SelectProps<T> = T extends string
? {
options: Array<string>;
labelProp?: never;
valueProp?: never;
}
: {
options: Array<T>;
labelProp: keyof T;
valueProp: keyof T;
};
export const Select = <T extends unknown>(props: SelectProps<T>) => {
return <div>{JSON.stringify(props)}</div>;
};
Here we changed the union type by ternary operator to check if our generic type is a string, and based on that, we set our component’s type to the appropriate option.
Top comments (5)
The biggest thing I took away from this is the need to represent all properties across all of the discriminated unions. The
?: never
type should be used for all unused properties in a specific union rather than simply omitting these unused properties. Not doing will cause issues as typescript is not able to choose from the correct union when properties are omitted. 👍Thanks for nicely summarizing the essence of the article 🎉
Considering you've copied almost word by word from another external blog post, the minimum you should've done is provide the link to the source from wich you copied the information.
For those interested, heres the link: benmvp.com/blog/conditional-react-...
Great tutorial, thanks a lot! I was battling with Typescript on how to accomplish this ❤️
Thanks for sharing this!
Some comments have been hidden by the post's author - find out more