Skip to main content

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

PatternType SafetyComplexityUse Case
Simple unionprop1 | prop2LowSimple optional props
Discriminated unionvariant === 'A' narrows typeMediumMultiple related variants
Exhaustive checkingnever type guardMediumEnsuring all cases handled
Generic constraintsGeneric type parametersHighPolymorphic/flexible components

Key Takeaways

  • Discriminated unions use a discriminator prop (like variant or type) 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 never type 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.

Further Reading