Discriminated Unions
Modeling variant-dependent props with TypeScript so impossible states are unrepresentable.
Status-specific message props
interface StatusProps {
status: 'loading' | 'error' | 'success';
errorMessage?: string;
successData?: string;
}type StatusProps =
| { status: 'loading' }
| { status: 'error'; errorMessage: string }
| { status: 'success'; data: string };A union type ties each status to exactly the props it needs. When status is 'loading', there's no errorMessage to accidentally render. When status is 'error', errorMessage is required — not optional. TypeScript narrows the type automatically when you check status.
Button action variants
type ButtonProps = {
children: React.ReactNode;
} & (
| {
/** Renders as an anchor element. */
href: string;
target?: '_blank' | '_self';
onClick?: () => void;
}
| {
/** Renders as a button element. */
onClick?: () => void;
type?: 'button' | 'submit';
href?: string;
}
);type ButtonProps = {
children: React.ReactNode;
} & (
| {
/** Renders as an anchor element. */
href: string;
target?: '_blank' | '_self';
onClick?: never;
type?: never;
}
| {
/** Renders as a button element. */
onClick?: () => void;
type?: 'button' | 'submit';
href?: never;
target?: never;
}
);The never type blocks invalid combinations at compile time. Without it, both onClick and href appear in both variants — the union doesn't actually constrain anything. With never, <Button href="/about" onClick={fn} /> is a type error. Each variant is truly exclusive.
Variant-dependent props
type AlertProps =
| {
/** Success state. */
type: 'success';
message: string;
/** Auto-dismiss delay. */
autoDismissMs?: number;
/** Retry handler. */
onRetry?: () => void;
/** Dismiss handler. */
onDismiss?: () => void;
}
| {
/** Error state. */
type: 'error';
message: string;
/** Retry handler. */
onRetry?: () => void;
/** Retry button text. */
retryLabel?: string;
/** Dismiss handler. */
onDismiss?: () => void;
}
| {
/** Warning state. */
type: 'warning';
message: string;
/** Dismiss handler. */
onDismiss?: () => void;
/** Retry handler. */
onRetry?: () => void;
/** Auto-dismiss delay. */
autoDismissMs?: number;
};type AlertProps =
| {
/** Shown on successful operations. Auto-dismisses. */
type: 'success';
message: string;
/**
* Time in ms before the alert auto-dismisses.
* @default 3000
*/
autoDismissMs?: number;
}
| {
/** Shown on failed operations. Offers a retry. */
type: 'error';
message: string;
/** Called when the user clicks the retry button. */
onRetry: () => void;
/** Label for the retry button. @default "Try again" */
retryLabel?: string;
}
| {
/** Shown for non-critical issues. Can be dismissed. */
type: 'warning';
message: string;
/** Called when the user dismisses the warning. */
onDismiss: () => void;
};Even though both use union types, the correct version makes each variant self-contained: onRetry is required (not optional) only for errors, autoDismissMs only exists on success, and onDismiss only on warnings.
The other version duplicates every prop across all variants as optional — a success alert can still have onRetry.
Controlled vs uncontrolled input
type InputProps = {
placeholder?: string;
} & (
| {
/** Controlled mode: parent owns value. */
value: string;
/** Called when the value changes. */
onChange: (value: string) => void;
defaultValue?: string;
}
| {
/** Uncontrolled mode: internal state. */
defaultValue?: string;
/** Called when the value changes. */
onChange?: (value: string) => void;
value?: string;
}
);type InputProps = {
placeholder?: string;
} & (
| {
/** Controlled mode: parent manages the value. */
value: string;
/** Required in controlled mode. */
onChange: (value: string) => void;
defaultValue?: never;
}
| {
/** Uncontrolled mode: initial value only. */
defaultValue?: string;
/** Optional in uncontrolled mode. */
onChange?: (value: string) => void;
value?: never;
}
);The never type is the key difference. Without it, both value and defaultValue are allowed in both variants — the union doesn't actually prevent mixing.
defaultValue?: never in controlled mode and value?: never in uncontrolled mode make TypeScript reject <Input value="hi" defaultValue="there" /> at compile time.
Image avatar vs initials avatar
interface AvatarProps {
src?: string;
alt?: string;
name?: string;
size?: 'sm' | 'md' | 'lg';
}type AvatarProps = {
size?: 'sm' | 'md' | 'lg';
} & (
| { src: string; alt: string }
| { name: string }
);An avatar is either an image (src + alt) or initials (name). The union makes both variants clear and ensures an image avatar always has alt text for accessibility. You can't accidentally pass src without alt or pass nothing at all.