React Union Props: Discriminated Unions for Type Safety
Discriminated unions (also called tagged unions) are a TypeScript pattern where a prop discriminator determines what other props are valid. This prevents invalid prop combinations—for example, you can't have both variant="success" and icon="error" at the same time. Discriminated unions enforce that components are used correctly and catch configuration mistakes at compile time.
Understanding Discriminated Unions
A discriminated union defines separate interface branches, each with a different discriminator value. TypeScript narrows the prop type based on the discriminator, ensuring only valid props for that variant are allowed:
// Alert.tsx
interface SuccessAlert {
variant: 'success';
message: string;
icon?: 'checkmark' | 'star';
}
interface ErrorAlert {
variant: 'error';
message: string;
errorCode: number;
}
interface WarningAlert {
variant: 'warning';
message: string;
dismissible?: boolean;
}
type AlertProps = SuccessAlert | ErrorAlert | WarningAlert;
const Alert: React.FC<AlertProps> = (props) => {
// TypeScript narrows the type based on props.variant
return (
<div className={`alert alert-${props.variant}`}>
<p>{props.message}</p>
{/* variant is 'success', so icon is valid */}
{props.variant === 'success' && props.icon && (
<span>{props.icon}</span>
)}
{/* variant is 'error', so errorCode is valid */}
{props.variant === 'error' && (
<p>Error code: {props.errorCode}</p>
)}
{/* variant is 'warning', so dismissible is valid */}
{props.variant === 'warning' && props.dismissible && (
<button>Dismiss</button>
)}
</div>
);
};
export default Alert;
Now callers must use the correct props for each variant:
// Valid: success variant with optional icon
<Alert variant="success" message="Done!" icon="checkmark" />
// Valid: error variant with required errorCode
<Alert variant="error" message="Failed" errorCode={500} />
// Valid: warning variant with optional dismissible
<Alert variant="warning" message="Caution" dismissible={true} />
// Error: success variant doesn't have errorCode property
<Alert variant="success" message="Done" errorCode={500} />
// Error: error variant doesn't have icon property
<Alert variant="error" message="Failed" icon="error" />
// Error: warning variant doesn't have errorCode property
<Alert variant="warning" message="Caution" errorCode={400} />
The discriminator (variant) tells TypeScript which props are valid for that branch.
Building a Button with Discriminated Variants
A button component might render differently based on its variant. Discriminated unions ensure each variant has the correct props:
// Button.tsx
interface LinkButton {
variant: 'link';
href: string;
target?: '_blank' | '_self';
}
interface ActionButton {
variant: 'action';
onClick: () => void;
type?: 'submit' | 'reset' | 'button';
loading?: boolean;
}
interface IconButton {
variant: 'icon';
icon: string;
ariaLabel: string;
onClick: () => void;
}
type ButtonProps = {
label: string;
disabled?: boolean;
} & (LinkButton | ActionButton | IconButton);
const Button: React.FC<ButtonProps> = (props) => {
const baseClass = `btn ${props.disabled ? 'disabled' : ''}`;
if (props.variant === 'link') {
return (
<a href={props.href} target={props.target} className={`${baseClass} btn-link`}>
{props.label}
</a>
);
}
if (props.variant === 'action') {
return (
<button
className={`${baseClass} btn-action ${props.loading ? 'loading' : ''}`}
onClick={props.onClick}
type={props.type || 'button'}
disabled={props.disabled}
>
{props.label}
</button>
);
}
return (
<button
className={`${baseClass} btn-icon`}
onClick={props.onClick}
aria-label={props.ariaLabel}
disabled={props.disabled}
>
{props.icon}
</button>
);
};
export default Button;
Usage with type safety:
// Valid: link button with href
<Button variant="link" label="Go" href="/page" />
// Valid: action button with onClick
<Button variant="action" label="Submit" onClick={() => {}} type="submit" />
// Valid: icon button with ariaLabel
<Button variant="icon" label="X" icon="✕" ariaLabel="Close" onClick={() => {}} />
// Error: link button doesn't have onClick
<Button variant="link" label="Go" href="/page" onClick={() => {}} />
// Error: action button doesn't have href
<Button variant="action" label="Submit" onClick={() => {}} href="/page" />
Each variant has its own required and optional props, preventing impossible combinations.
Discriminated Unions with Shared Props
Combine shared props that apply to all variants with discriminated branches:
// Card.tsx
interface BaseCardProps {
title: string;
className?: string;
onClose?: () => void;
}
interface ImageCard extends BaseCardProps {
type: 'image';
imageUrl: string;
altText: string;
}
interface TextCard extends BaseCardProps {
type: 'text';
content: string;
readMore?: string;
}
interface VideoCard extends BaseCardProps {
type: 'video';
videoId: string;
thumbnail?: string;
}
type CardProps = ImageCard | TextCard | VideoCard;
const Card: React.FC<CardProps> = (props) => (
<div className={`card card-${props.type} ${props.className}`}>
<h3>{props.title}</h3>
{props.type === 'image' && (
<img src={props.imageUrl} alt={props.altText} />
)}
{props.type === 'text' && (
<>
<p>{props.content}</p>
{props.readMore && <a href={props.readMore}>Read more</a>}
</>
)}
{props.type === 'video' && (
<iframe
src={`https://youtube.com/embed/${props.videoId}`}
title={props.title}
/>
)}
{props.onClose && <button onClick={props.onClose}>Close</button>}
</div>
);
export default Card;
Narrowing with Type Guards
Inside the component, TypeScript automatically narrows prop types based on the discriminator:
const Card: React.FC<CardProps> = (props) => {
// At this point, TypeScript doesn't know which variant
if (props.type === 'image') {
// Now TypeScript knows props is ImageCard
// props.imageUrl and props.altText are available
return <img src={props.imageUrl} alt={props.altText} />;
}
if (props.type === 'text') {
// Now props is TextCard
return <p>{props.content}</p>;
}
// In an exhaustive conditional, props.type is 'video'
return <iframe src={`https://youtube.com/embed/${props.videoId}`} />;
};
Type narrowing is automatic—TypeScript tracks the discriminator and knows which properties are available.
Exhaustive Checking with Never
Ensure all union branches are handled by using the never type:
const Alert: React.FC<AlertProps> = (props) => {
switch (props.variant) {
case 'success':
return <div className="alert-success">{props.message}</div>;
case 'error':
return <div className="alert-error">{props.message} {props.errorCode}</div>;
case 'warning':
return <div className="alert-warning">{props.message}</div>;
default:
// If you forget a case, TypeScript will error here
const exhaustive: never = props;
return exhaustive;
}
};
If a new variant is added to AlertProps but you don't handle it, the default case will have a type error, alerting you to update the switch.
Comparison Table: Validation Patterns
| Pattern | Type Safety | Complexity | Use Case |
|---|---|---|---|
| Simple union | prop1 | prop2 | Low | Simple optional props |
| Discriminated union | variant === 'A' narrows type | Medium | Multiple related variants |
| Exhaustive checking | never type guard | Medium | Ensuring all cases handled |
| Generic constraints | Generic type parameters | High | Polymorphic/flexible components |
Key Takeaways
- Discriminated unions use a discriminator prop (like
variantortype) to determine which other props are valid. - TypeScript automatically narrows the type inside conditional branches, making variant-specific props available.
- Combine shared base props with discriminated branches to avoid repetition while maintaining type safety.
- Use exhaustive checking with the
nevertype to ensure all union variants are handled. - Discriminated unions prevent invalid prop combinations at compile time, catching configuration mistakes early.
Frequently Asked Questions
What's the difference between a discriminated union and a single interface with optional props?
A single interface with optional props is permissive—TypeScript won't error if you pass conflicting props. A discriminated union enforces that only certain props can exist together, catching invalid combinations. For type safety, discriminated unions are superior.
Can I use a string literal type as a discriminator?
Yes—string literal types ('success' \| 'error' \| 'warning') are the most common discriminators. You can also use number literals or enum values.
How do I handle a discriminated union with many variants?
Use a switch statement with exhaustive checking (default: const x: never = props), or if statements. For very large unions, consider organizing variants into groups or using helper functions.
Can discriminated unions be nested?
Yes—a union branch can contain another union. For example, ErrorAlert might have a discriminated errorType that further narrows behavior. TypeScript supports arbitrary nesting.
Do I need to import/export discriminated union types?
Yes—export the full union type so other files can import and use it. Export individual branches too if components are used independently.