Half of all bad React APIs come from optional props that quietly imply 'sometimes you need these together, never those.' Discriminated unions make those rules explicit and let the compiler enforce them.
The shape
A discriminated union is a union of object types where every variant shares a single literal field (the discriminant) with a different value per variant. TypeScript uses the discriminant to narrow the union — once you check the discriminant in code, the compiler knows which variant you're in.
The classic Button-or-Link problem
You want one component that renders a styled button when you pass onClick, and a styled anchor when you pass href. With optional props you'd get { onClick?: () => void; href?: string } — which compiles even when both are passed (ambiguous) or neither (useless). The discriminated-union version makes the contract exclusive: pick exactly one variant.
Narrowing inside the component
Once you check the discriminant (if (props.variant === 'link')), TypeScript narrows the type — inside that block, href is required, onClick doesn't exist. No type assertions, no !. The compiler walks the union for you.
Where this scales
Beyond Button/Link, discriminated unions are everywhere:
- Fetch result:
{ status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T }. - Form state:
{ phase: 'editing'; draft: ... } | { phase: 'submitting' } | { phase: 'saved'; saved: ... }. - Cinder's protocol envelope:
{ type: 'preview.captured'; payload: ... } | { type: 'generation.candidate_ready'; payload: ... }— exactly the shapepackages/protocolin cwkCinder will use.
{ loading: true, error: 'oops' } is a type that will eventually produce that bug. Discriminated unions are how you reach into the type system and remove the bug before it's written.