Some time ago we discussed discriminated unions as a super-feature of TypeScript which helps in keeping business rules straight in the code. Here is how we can transform this idea into React ecosystem. Let’s make sure our components are used in the right way.
Building generic components in react we have to keep a balance between developer experience and vary of options to customize.
We meet a problem when our component takes too much responsibility, which drives us into bizarre situations, when we have to handle a growing amount of use cases and variants;
It’s time for a sample. Today we consider a banner, which has different styles depending on type:
type Props = {
type: "success" | "info" | "warning" | "error";
label: string;
onRetry?: () => void;
onDismiss?: () => void;
};
const Banner = (props: Props) => {
if (props.type === "error" && props.onRetry) {
return (
<div className="banner bg-error">
{props.label}
<button onClick={props.onRetry}>Retry</button>
</div>
);
}
if (props.type === "warning" && props.onDismiss) {
return (
<div className="banner bg-warning">
{props.label} <button onClick={props.onDismiss}>x</button>
</div>
);
}
if (props.type === "success") {
return <div className="banner bg-success">{props.label}</div>;
}
return <div className="banner bg-info">{props.label}</div>;
};
As you may see, it’s not very complex, but at the first glance we can see that typing is not strong. Based on Props
we require only label
, but other fields are not very intuitive. We cannot be sure when we should pass onRetry
or onDismiss
.
It increases chaos and complexity in our project. The thing we have to do is to…
Thanks to discriminated unions, we can set strict rules and behaviors of our components.
Let’s start with a rough plan, how our component should behave:
if type of Banner
is error
, then we should be able to retry
if type of Banner
is warning
, then we should be able to dismiss
if type of Banner
is success
or info
we shouldn’t have any additional actions
each Banner
should have label
This is how our plan looks like in the code as Props
type:
type Props = {
label: string;
} & (
| {
type: "success" | "info";
}
| {
type: "error";
onRetry: () => void;
}
| {
type: "warning";
onDismiss: () => void;
}
);
We used here three variants, one for each type
- which is our differentiator. We also grouped those which have the same API (success
and info
). In addition, intersection (&
) allows us to connect one required field (label
).
How will it look like in the component?
const Banner = (props: Props) => {
switch (props.type) {
case "error":
return (
<div className="banner bg-error">
{props.label}
<button onClick={props.onRetry}>Retry</button>
</div>
);
case "warning":
return (
<div className="banner bg-warning">
{props.label}
<button onClick={props.onDismiss}>x</button>
</div>
);
case "success":
return <div className="banner bg-success">{props.label}</div>;
case "info":
return <div className="banner bg-info">{props.label}</div>;
}
};
Thanks to switch
statement, we could cover all variants and make our code more readable. Discriminated union also drives us to use proper methods in each case. If we use onRetry
in the case of success
type, then TypeScript compilator would scream that Property 'onRetry' does not exist on type '{ label: string; } & { type: "success" | "info"; }'
! How cool is that!
Let’s take a look how can we work with our Button
component:
/**
Type '{ type: "info"; onRetry: () => void; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'onRetry' does not exist on type 'IntrinsicAttributes & { label: string; } & { type: "success" | "info"; }'
**/
const Sample = () => (
<Banner label="Info text" type="info" onRetry={() => {}} />
);
We tried to add onRetry
to type info
, which is an invalid property. TypeScript knows that and doesn’t let us do this.
On the other hand, if we forget about some obligatory props, then TS will gently inform us that something went wrong:
/**
Type '{ type: "error"; label: string; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'onRetry' is missing in type '{ type: "error"; label: string; }' but required in type '{ type: "error"; onRetry: () => void; }'
**/
const Sample = () => <Banner label="Error text" type="error" />;
Thanks to that simple trick, you can seal your code and create your design system with confidence, that everyone knows, how to use it. Even if not, TypeScript will see to it on behalf of us.