P

TypeScript Discriminated Unions as a Design System Contract: Eliminating Prop Explosion in Component Libraries

React internals and practical implications
TypeScript patterns for large codebases
TypeScript
·26 Min. Lesezeit
Prasath Soosaithasan
von Prasath Soosaithasan
TypeScript Discriminated Unions as a Design System Contract: Eliminating Prop Explosion in Component Libraries

The Problem Has a Name

Every component library of sufficient age develops the same disease. A Button component starts with five props. Eighteen months later it has thirty-seven, half of which are mutually exclusive, and the only documentation is a Slack thread from someone who left the company. This is prop explosion, and it is not a complexity problem — it is a design problem. Specifically, it is the absence of a type-level contract that encodes which combinations of props are actually legal.

The standard approach — a flat interface with a growing list of optional properties — is a lie. It tells TypeScript that every combination is valid. It tells consumers that they need to read the source to figure out which ones actually work. And it tells the next developer maintaining the component that they are on their own.

The worst part is not the confusion. It is the silence. A developer passes actions={bulkActions} to a table with no selection mode enabled. The prop is accepted. TypeScript is happy. ESLint is happy. The test suite is happy. The bulk actions never appear on screen because they are meaningless without selection — but the developer does not know that. They think the feature is wired up. It is not. Nothing told them. This is not a hypothetical scenario. It happened on a production project, and it stayed hidden until someone asked why the bulk actions button was missing from a specific view.

What Discriminated Unions Actually Buy You

A discriminated union in TypeScript is a union of object types that share a common literal-typed field — the discriminant. The compiler uses this field to narrow the type, which means it knows exactly which properties are available in each branch. This is not an advanced feature. It has been stable since TypeScript 2.0. The fact that most component libraries still do not use it is instructive.

1type ButtonProps =
2 | { kind: "action"; onClick: () => void; loading?: boolean }
3 | { kind: "navigation"; href: string; external?: boolean }
4 | { kind: "icon-only"; icon: ReactNode; ariaLabel: string };

With this shape, TypeScript will error if you pass href to an action button or loading to a navigation button. The illegal states are not just discouraged — they are unrepresentable. This is the core principle: make wrong code fail to compile.

Note that kind is not variant. In most design systems, variant controls visual styling — primary, secondary, danger, success. The discriminant here is different: it encodes the behavioural contract of the component. An action button handles clicks. A navigation button renders an anchor. An icon-only button requires an accessible label. These are different components wearing the same name — and the discriminant makes that explicit. Each kind can still accept any variant for styling.

Contrast with the Flat Interface

1interface ButtonProps {
2 kind: "action" | "navigation" | "icon-only";
3 variant?: "primary" | "secondary" | "danger";
4 onClick?: () => void;
5 href?: string;
6 external?: boolean;
7 loading?: boolean;
8 icon?: ReactNode;
9 ariaLabel?: string;
10}

This interface permits a button with kind="icon-only", no ariaLabel, and a loading prop that the icon-only kind silently ignores. The type checker passes. The screen reader fails. The accessibility audit finds it three months later. Everyone is unhappy.

The Discriminant Is the Contract

The key insight is that the discriminant field is not just a type narrowing mechanism — it is a contract between the design system team and every consumer of the component. When you choose kind: "navigation", you are opting into a specific set of capabilities and responsibilities. The type system enforces this automatically.

This has three concrete benefits:

  • Autocompletion becomes documentation. After typing kind: "navigation", the editor only suggests href and external. The developer does not need to consult Storybook or grep the source. The type is the API surface.
  • Refactoring is safe. Adding a required prop to the icon-only kind produces compile errors at every call site that uses that kind — and only those call sites. No shotgun surgery.
  • Runtime validation becomes optional. If the type system already prevents illegal combinations, you can drop the defensive if (kind === "navigation" && !href) checks that litter component internals. The code gets shorter and the bundle gets smaller.

Case Study: DataTable at AXA in Winterthur — 45 Props, 75 Call Sites, One Refactor

Theory is cheap. Here is what happened when we rolled out this pattern on a real project: a shared DataTable component in an Nx monorepo at AXA in Winterthur, consumed by 75+ call sites across two applications. The component accepted approximately 45 optional props through a single flat interface. It was the most-used component in the entire design system, and it was a textbook case of prop explosion.

Before: Boolean Soup

The flat interface used two boolean flags to control selection behaviour:

1// BEFORE — DataTable.tsx
2export interface Props<TData extends RowData> extends VariantProps {
3 name: string
4 className?: string
5 data: TData[]
6 columns: ColumnDef<TData>[]
7 isLoading?: boolean
8 zebra?: boolean
9 compact?: boolean
10
11 // Selection mode expressed as two booleans
12 singleRowSelectionEnabled?: boolean
13 multiRowSelectionEnabled?: boolean
14
15 // Props only meaningful for single selection
16 onSingleRowSelection?: (row: TData) => void
17
18 // Props only meaningful for multi selection
19 onMultiRowSelection?: (rows: TData[]) => void
20 actions?: DataTableMultiRowAction<TData>[]
21 showSelectedRowsActions?: boolean
22 selectedRowsLabel?: SelectedRowLabelFn
23
24 // Props only meaningful when ANY selection is enabled
25 getCanSelectRow?: (row: TData) => boolean
26 rowSelection?: RowSelectionState
27 onRowSelectionChange?: (rowSelection: RowSelectionState) => void
28 initialRowSelection?: RowSelectionState
29
30 // ... ~20 more optional props
31}

Two booleans. Three meaningful states (no selection, single, multi). Four possible boolean combinations — one of which (singleRowSelectionEnabled && multiRowSelectionEnabled) is a contradiction that the component handled with a silent runtime reset:

1// useRowSelection.ts — BEFORE
2if (singleRowSelectionEnabled && multiRowSelectionEnabled) {
3 table.setRowSelection({}) // silently reset — a runtime band-aid
4}

Nothing in the type system prevented a consumer from triggering this branch. Nothing prevented passing actions to a table with no selection enabled. Nothing prevented passing onSingleRowSelection alongside multiRowSelectionEnabled. The flat interface accepted all 45 props as a grab-bag and left the component to sort out the mess at runtime.

And here is the scenario that made the problem real: a developer on the team passed actions={bulkActions} to a DataTable that had no selection mode enabled. They expected bulk action buttons to appear. They did not. The prop was silently accepted — TypeScript did not complain, ESLint did not flag it, the tests passed. The developer spent time debugging why the buttons were missing, never suspecting that the prop itself was meaningless in that context. The flat interface had told them it was a valid prop. It was not.

Every internal component and hook had to destructure and reason about two separate booleans:

1// TableRow.tsx — BEFORE
2const hoverEnabled = multiRowSelectionEnabled || singleRowSelectionEnabled
3
4if (singleRowSelectionEnabled && !multiRowSelectionEnabled) {
5 table.setRowSelection({ [rowId]: checked })
6} else if (multiRowSelectionEnabled) {
7 // shift-click range selection logic...
8}

On top of this, the Props interface extended VariantProps from the CVA wrapper, which leaked internal styling keys like multiRowSelectionEnabled, hasRowSelection, and showSelectedRowsActions into the public API — even though hasRowSelection was always computed at runtime and never set by consumers.

Identifying the Discriminant

The first step — and the step most developers skip — was auditing all 75+ consumer call sites. The audit revealed a clean distribution:

  • ~50 tables: No selection at all. Zero selection props passed.
  • ~20 tables: Multi-row selection. Used onMultiRowSelection, actions, showSelectedRowsActions.
  • ~5 tables: Single-row selection. Used onSingleRowSelection.

Not a single call site used both boolean flags simultaneously. The two booleans were a discriminant in disguise — they encoded exactly three mutually exclusive modes. The replacement: a single string literal field, selectionMode: "none" | "single" | "multi".

After: The Discriminated Union

1// AFTER — DataTable.tsx
2
3/** Base props shared by all selection modes */
4interface BaseProps<TData extends RowData> {
5 variant?: Variant
6 name: string
7 className?: string
8 data: TData[]
9 columns: ColumnDef<TData>[]
10 isLoading?: boolean
11 zebra?: boolean
12 compact?: boolean
13 paginationEnabled?: boolean
14 rowsPerPage?: number
15 // ... shared props
16}
17
18/** Selection-specific props shared between single and multi modes */
19interface SelectionBaseProps<TData extends RowData> {
20 getCanSelectRow?: (row: TData) => boolean
21 rowSelection?: RowSelectionState
22 onRowSelectionChange?: (rowSelection: RowSelectionState) => void
23 initialRowSelection?: RowSelectionState
24}
25
26/** No row selection (default when selectionMode is omitted) */
27interface NoSelectionProps<TData extends RowData>
28 extends BaseProps<TData> {
29 selectionMode?: "none"
30}
31
32/** Single-row selection mode */
33interface SingleSelectionProps<TData extends RowData>
34 extends BaseProps<TData>, SelectionBaseProps<TData> {
35 selectionMode: "single"
36 onSingleRowSelection?: (row: TData) => void
37}
38
39/** Multi-row selection mode */
40interface MultiSelectionProps<TData extends RowData>
41 extends BaseProps<TData>, SelectionBaseProps<TData> {
42 selectionMode: "multi"
43 onMultiRowSelection?: (rows: TData[]) => void
44 actions?: DataTableMultiRowAction<TData>[]
45 showSelectedRowsActions?: boolean
46 selectedRowsLabel?: SelectedRowLabelFn
47}
48
49export type Props<TData extends RowData> =
50 | NoSelectionProps<TData>
51 | SingleSelectionProps<TData>
52 | MultiSelectionProps<TData>

Four design decisions worth noting:

  • selectionMode?: "none" — optional on the default branch. The ~50 tables with no selection pass zero selection props. Making selectionMode optional on NoSelectionProps means they require zero migration effort. The component treats undefined as equivalent to "none".
  • SelectionBaseProps — shared between single and multi. Props like getCanSelectRow, rowSelection, and onRowSelectionChange are meaningful for both selection modes. They were extracted into a shared interface that both branches extend, avoiding duplication without compromising type safety.
  • ResolvedProps — internal flat type for the context chain. React Context and the TableProvider need a flat shape — you cannot spread a discriminated union into context cleanly. A ResolvedProps type was introduced for internal plumbing, with the union resolved to a flat shape at the provider boundary.
  • CVA VariantProps decoupled from the public API. The extends VariantProps was removed from the public Props. The component now derives internal CVA values like multiRowSelectionEnabled: selectionMode === "multi" when calling the CVA function, keeping styling internals out of the consumer-facing contract.

What the Type System Now Catches

After the refactoring, every one of these illegal combinations produces a compile error:

1// ERROR: Property 'actions' does not exist on type 'NoSelectionProps'
2<DataTable name="t" data={[]} columns={[]} actions={bulkActions} />
3
4// ERROR: Property 'onSingleRowSelection' does not exist on type 'MultiSelectionProps'
5<DataTable
6 selectionMode="multi"
7 onSingleRowSelection={(row) => console.log(row)}
8/>
9
10// ERROR: Property 'onMultiRowSelection' does not exist on type 'SingleSelectionProps'
11<DataTable
12 selectionMode="single"
13 onMultiRowSelection={(rows) => console.log(rows)}
14/>
15
16// ERROR: Property 'getCanSelectRow' does not exist on type 'NoSelectionProps'
17<DataTable name="t" data={[]} columns={[]}
18 getCanSelectRow={(row) => !row.isLocked}
19/>

All of these compiled without error before the refactoring. The developer who passed actions to a table with no selection enabled would now get an immediate red underline in their editor. No debugging session. No wasted time. The type system tells them: this prop does not exist on this variant.

The Ripple Effect: 9 Internal Files, Zero Runtime Changes

The refactoring touched 9 internal files beyond the main component. Here is what changed and why.

Context chain (3 files): TableContext.ts, TableProvider.tsx, and types/index.ts. The context type switched from extending the flat interface plus VariantProps to extending ResolvedProps. The derived boolean rowSelectionEnabled simplified from multiRowSelectionEnabled || singleRowSelectionEnabled to selectionMode !== "none". The VariantProps export — which had leaked CVA styling internals into the public API — was removed entirely.

Internal hooks (2 files): useRowSelection.ts and useTextSelection.ts. The impossible-state guard was deleted — the type system now prevents the contradiction at compile time. What was a six-line useEffect with a defensive if (singleRowSelectionEnabled && multiRowSelectionEnabled) check became three lines that handle only the legitimate single case:

1// useRowSelection.ts — AFTER
2interface UseRowSelectionArgs<TData extends RowData> {
3 selectionMode: "none" | "single" | "multi"
4 table: Table<TData>
5}
6
7export const useRowSelection = <TData>({
8 selectionMode,
9 table,
10}: UseRowSelectionArgs<TData>) => {
11 useEffect(() => {
12 if (selectionMode === "single") {
13 table.setRowSelection((prev) => {
14 const selectedRowIds = Object.keys(prev)
15 if (selectedRowIds.length > 1) {
16 return { [selectedRowIds[0]]: prev[selectedRowIds[0]] }
17 }
18 return prev
19 })
20 }
21 }, [selectionMode])
22}

Internal child components (4 files): TableRow.tsx, HeaderRow.tsx, EmptyRow.tsx, SelectedRowsActions.tsx. All read from useTableContext(). The replacement was mechanical — every instance of multiRowSelectionEnabled || singleRowSelectionEnabled became selectionMode !== "none". Every branching if (singleRowSelectionEnabled && !multiRowSelectionEnabled) became if (selectionMode === "single").

The replacement cheat sheet tells the whole story:

1// Old → New
2multiRowSelectionEnabled || singleRowSelectionEnabled → selectionMode !== "none"
3multiRowSelectionEnabled → selectionMode === "multi"
4singleRowSelectionEnabled → selectionMode === "single"
5singleRowSelectionEnabled && !multiRowSelectionEnabled → selectionMode === "single"
6singleRowSelectionEnabled && multiRowSelectionEnabled → Dead code — removed

Boolean arithmetic replaced with domain language. The code reads like intent instead of logic puzzles.

What the Numbers Say

  • 75+ call sites. The ~50 tables with no selection required zero changes (because selectionMode is optional on the default branch). The ~25 tables with selection needed a one-line change: replace the boolean flag with the appropriate selectionMode value.
  • 1 impossible state eliminated. The contradictory combination of both boolean flags is now unrepresentable. The runtime guard that silently swallowed the error is gone.
  • 9 internal files simplified. Every file that branched on two booleans now branches on one string literal. The boolean arithmetic (a || b, a && !b) is replaced with direct equality checks.
  • Zero runtime behaviour changes. The rendered DOM is identical. The bundle is marginally smaller (deleted validation code). No visual regression.
  • ~27 files total, +406 / -190 lines. The majority of new lines (~220) came from Storybook story wrapper components that had to branch on selectionMode to satisfy the union — a trade-off of strict typing at intermediate boundaries. Core library changes were net-negative on line count.

Implementation Patterns That Work

Pattern 1: Discriminant-Driven Components

The most common pattern. A single component accepts a discriminated union and uses the discriminant to select rendering logic. The implementation mirrors the type:

1function Button(props: ButtonProps) {
2 switch (props.kind) {
3 case "action":
4 return <button onClick={props.onClick} disabled={props.loading}>...</button>;
5 case "navigation":
6 return <a href={props.href} target={props.external ? "_blank" : undefined}>...</a>;
7 case "icon-only":
8 return <button aria-label={props.ariaLabel}>{props.icon}</button>;
9 }
10}

The switch is exhaustive. If you add a new kind to the union, TypeScript flags every switch that does not handle it. This is the never exhaustiveness check, and it is worth enabling via a default branch: const _exhaustive: never = props;.

Pattern 2: Polymorphic as Prop with Constraints

Some libraries use an as prop to change the rendered element. This is polymorphism, and it pairs naturally with discriminated unions. The discriminant is the element type, and each branch carries the correct HTML attributes:

1type BoxProps =
2 | { as: "a"; href: string } & AnchorHTMLAttributes<HTMLAnchorElement>
3 | { as: "button"; type?: "button" | "submit" } & ButtonHTMLAttributes<HTMLButtonElement>
4 | { as?: "div" } & HTMLAttributes<HTMLDivElement>;

This prevents the classic bug of passing href to a <button> or type="submit" to an <a>. Libraries like Radix UI and Chakra UI have explored this pattern with varying degrees of type safety. The discriminated union version is the strictest.

Pattern 3: Compound State Machines

For components with complex state — modals, multi-step forms, data tables with selection modes — the discriminant can encode the component's state machine. This is exactly what the DataTable refactoring did:

1type TableProps =
2 | { selectionMode: "none" }
3 | { selectionMode: "single"; onSelect: (id: string) => void; selected?: string }
4 | { selectionMode: "multi"; onSelect: (ids: string[]) => void; selected?: string[] };

The callback signature changes with the selection mode. A flat interface would need onSelect: ((id: string) => void) | ((ids: string[]) => void), which is unusable. The discriminated union makes each mode a self-contained, well-typed API.

The Migration Path: Flat to Discriminated

Nobody rewrites a component library in a weekend. The pragmatic migration looks like this:

  1. Identify the implicit discriminant. Most flat interfaces already have one — kind, type, mode, as. The field that consumers use to mentally select "which kind of component is this" is your discriminant. Do not confuse this with variant, which in most design systems controls visual styling (primary, danger, success). The discriminant encodes behavioural differences — different props, different contracts, different rendering paths. Sometimes it is not a single field but a pair of mutually exclusive booleans — singleRowSelectionEnabled and multiRowSelectionEnabled, for instance. Two booleans encoding three states (neither, one, or the other) is a discriminant begging to be extracted. That is exactly the pattern we found in the DataTable.
  2. Map the dependency graph. For each value of the discriminant, list which other props are required, optional, or illegal. This is the hardest step because it often reveals undocumented coupling. If loading only makes sense for kind="action" but your interface allows it everywhere, you have found your first union branch.
  3. Extract the union type. Write the discriminated union alongside the existing flat interface. Use a type compatibility check (type _Check = FlatProps extends UnionProps ? true : false) to verify that the union is at least as permissive as the existing usage.
  4. Swap the component signature. Change the prop type. Fix the resulting compile errors. Each error is a call site that was relying on an illegal combination — this is the migration paying for itself.
  5. Deprecate the flat type. Export both for a release cycle if you have external consumers. Then remove the flat type.

Step 4 is where the value materialises. Every compile error you fix is a bug that was either silent or waiting to happen. In the DataTable migration, the ~50 tables with no selection required zero changes because selectionMode was optional on the default branch. The ~25 tables with selection needed a one-line change each: replace the boolean flag with selectionMode="single" or selectionMode="multi".

Where This Gets Tricky

Spread Props and the Loss of Narrowing

Destructuring kills narrowing. This is a TypeScript limitation that catches people off guard:

1// ❌ Narrowing lost after destructure
2function Button({ kind, ...rest }: ButtonProps) {
3 if (kind === "navigation") {
4 rest.href; // Error: Property 'href' does not exist
5 }
6}
7
8// ✅ Narrow on the whole object
9function Button(props: ButtonProps) {
10 if (props.kind === "navigation") {
11 props.href; // Works
12 }
13}

This means component internals must use props.field syntax rather than destructured variables. Some developers find this annoying. It is a minor syntactic cost for a major safety gain. The TypeScript team has discussed improving this (issue #46680), but for now, discipline is required.

React Context and the Provider Boundary

The DataTable refactoring surfaced a pattern worth calling out explicitly. Discriminated unions do not compose cleanly with React.createContext — you cannot spread a union into a context value and expect downstream consumers to narrow on it. The solution is a ResolvedProps type at the provider boundary: the component's public API accepts the discriminated union, the provider resolves it into a flat internal shape via a switch, and all child components and hooks consume the flat shape from context. The type safety lives at the entry point; the internals get a pre-narrowed, ergonomic type to work with.

1/** Resolved (flattened) props used internally after discriminating the union */
2export interface ResolvedProps<TData extends RowData> extends BaseProps<TData> {
3 selectionMode: "none" | "single" | "multi"
4 showSelectedRowsActions: boolean
5 getCanSelectRow?: (row: TData) => boolean
6 rowSelection?: RowSelectionState
7 onRowSelectionChange?: (rowSelection: RowSelectionState) => void
8 initialRowSelection?: RowSelectionState
9 onSingleRowSelection?: (row: TData) => void
10 onMultiRowSelection?: (rows: TData[]) => void
11 actions?: DataTableMultiRowAction<TData>[]
12 selectedRowsLabel?: SelectedRowLabelFn
13}

Generic Components and Conditional Props

When the discriminant is a generic type parameter — <T extends "single" | "multi"> — TypeScript's narrowing does not always work inside the component body. The workaround is function overloads or explicit type assertions at the boundary. This is less elegant but still safer than the flat interface approach.

interface extends Union Does Not Work

A wrapper component that had interface Props extends TableProps<TData> broke after the refactoring — TypeScript does not allow extending unions with interfaces. The fix is simple: type Props = TableProps<TData>. This bit several story wrapper components in the DataTable migration and is worth knowing about upfront.

Storybook and Testing

Story files need to pick a specific kind. The satisfies operator (TypeScript 4.9+) helps here:

1export const NavigationButton = {
2 args: {
3 kind: "navigation",
4 href: "/about",
5 external: false,
6 } satisfies ButtonProps,
7};

Without satisfies, Storybook's loose typing can mask the same illegal combinations the union was designed to prevent. Testing utilities like render(<Button {...props} />) work identically — the type system catches bad test data at compile time.

Performance and Bundle Impact

Discriminated unions are a compile-time construct. They produce zero runtime overhead. The switch statement in the component implementation is the same branching logic you would write regardless — the union just forces you to handle every branch.

If anything, the bundle gets smaller. Runtime prop validation (prop-types, manual checks, invariant assertions) can be removed because the type system guarantees the correct shape at build time. The DataTable refactoring deleted an entire impossible-state guard and its associated useEffect — small savings per component, but they add up across a library with thirty components.

Scaling to a Full Design System

Individual components are the easy win. The real leverage comes when the discriminant propagates through the system:

  • Theme tokens: A ColorScheme union where each variant carries its own contrast ratios and accessible pairings, preventing WCAG violations at the type level.
  • Layout primitives: A Stack that accepts direction: "horizontal" with align but not wrap, and direction: "vertical" with wrap but different align options.
  • Form fields: An Input union where type: "select" requires options, type: "text" accepts maxLength, and type: "file" carries accept and multiple.

The pattern is fractal. Each level of the component tree can enforce its own discriminated contract, and the constraints compose. A FormField that wraps Input can expose the same union, preserving type safety from the page layout down to the DOM element.

The Cultural Argument

The technical case is straightforward. The harder sell is cultural. Teams accustomed to any-heavy codebases or loose interfaces see discriminated unions as friction. The counter-argument is simple: the friction is not new. It was always there — in Slack questions, in code review comments, in production bugs, in onboarding time. The union moves the friction from runtime to compile time, from humans to machines, from expensive to cheap.

A design system is a contract between the platform team and every product team that consumes it. Contracts should be explicit, enforceable, and legible. A flat interface with thirty optional props is none of these things. A discriminated union is all three.

On the AXA project, the team's initial reaction was predictable — "this is more verbose, why can't we just keep the booleans?" The answer arrived within the first week: a developer who had been wiring up actions on a table without selection got an immediate compile error instead of a silent no-op. That one moment — the type system telling them exactly what was wrong and why — converted the sceptics faster than any presentation could have.

Stop Refactoring Manually — Give It to Your Coding Agent

Everything above is the theory and the real-world proof. Here is the practical part: in 2026, you should not be doing this refactoring by hand. This is exactly the kind of mechanical, rule-heavy transformation that a coding agent — Claude Code, Codex, Cursor, whatever you use — handles well, as long as you give it a precise instruction. Vague prompts produce vague results. A tight spec produces a tight refactor.

Below is a copy-paste instruction you can hand directly to your coding agent. Point it at a component directory, and it will do the migration for you.

The Instruction

Copy this entire block. Paste it into your coding agent. Replace the path on the first line.

1Refactor the component(s) in `src/components/Button` (adjust this path) from flat
2prop interfaces to TypeScript discriminated unions. Follow these rules exactly:
3
41. ANALYSE FIRST, CHANGE SECOND
5 - Read every file in the target directory and all consumer call sites across the
6 codebase (grep for imports of the component and its prop types).
7 - Identify the implicit discriminant field — the prop that determines "which kind"
8 of this component is being used (usually `kind`, `type`, `mode`, or `as`).
9 Do NOT use `variant` as the discriminant if it controls visual styling
10 (primary/secondary/danger) — that is a separate concern. Look for the prop
11 that determines behavioural differences: different rendered elements, different
12 required props, different interaction contracts.
13 The discriminant might also be a pair of mutually exclusive boolean flags —
14 two booleans that encode three states (neither, one, the other) are a
15 discriminant in disguise.
16 - For each value of that discriminant, catalogue which other props are required,
17 optional, or never used. Check actual usage in call sites, not just the interface.
18
192. BUILD THE DISCRIMINATED UNION
20 - Create a union type where each branch is keyed by a literal value of the
21 discriminant field.
22 - Each branch contains ONLY the props that are relevant to that variant:
23 required props stay required, optional props stay optional, irrelevant props
24 are excluded (not optional — absent).
25 - Extract shared props (children, className, style, testId, variant, etc.) into a
26 `CommonProps` base type and intersect it with each branch.
27 - If two or more branches share a subset of non-common props (e.g. selection
28 state props shared between single and multi modes), extract those into their
29 own shared interface and extend from it — do not duplicate definitions.
30 - If one branch is the "default" or "no-op" mode (e.g. no selection, no action),
31 make the discriminant optional on that branch so existing call sites that omit
32 it entirely require zero changes.
33 - Export the union type with the same name as the original interface so
34 external consumers pick up the change.
35
363. UPDATE THE COMPONENT IMPLEMENTATION
37 - Do NOT destructure the props object at the function signature level.
38 Use `props.field` access to preserve TypeScript narrowing.
39 - Use a `switch (props.discriminant)` with an exhaustive check:
40 add a `default` branch with `const _exhaustive: never = props;` to catch
41 unhandled variants at compile time.
42 - Remove any runtime prop validation that is now enforced by the type system
43 (e.g. `if (kind === "navigation" && !href) throw ...`). The types make this
44 dead code. Pay special attention to impossible-state guards — if the flat
45 interface allowed contradictory boolean flags and the component checked for
46 that at runtime, those checks are now dead code.
47 - If the component passes props through React Context or a provider, introduce
48 a `ResolvedProps` internal type (flat, non-union) for the context value.
49 Resolve the discriminated union to this flat shape at the provider boundary
50 — not in every consumer.
51
524. PROPAGATE THROUGH INTERNAL ARCHITECTURE
53 - Update React Context types and default values to use the new discriminant
54 instead of scattered booleans.
55 - Update all internal hooks that received the old boolean flags — replace them
56 with the single discriminant field.
57 - Update all internal child components that read from context to use the
58 discriminant (e.g. replace `multiRowSelectionEnabled || singleRowSelectionEnabled`
59 with `selectionMode !== 'none'`).
60 - Remove any CVA VariantProps or similar style-variant type extensions that
61 leaked internal implementation details into the public prop interface.
62
635. FIX ALL CONSUMER CALL SITES
64 - Run `tsc --noEmit` after the refactor. Every error is a call site passing
65 an illegal prop combination.
66 - Fix each one. If a call site was relying on an impossible combination
67 (e.g. `kind="icon-only"` with `loading`), determine the correct intent from
68 context and fix it — do not just cast to `any`.
69 - If a call site's intent is ambiguous, leave a `// TODO: verify intent`
70 comment rather than guessing.
71
726. UPDATE TESTS AND STORIES
73 - Update Storybook stories to use `satisfies ComponentProps` for type safety.
74 - Update test files so that test props match a valid union branch.
75 - Do not delete test coverage — adapt it to the new types.
76
777. VERIFY
78 - Run `tsc --noEmit` — zero errors.
79 - Run the project's test suite — zero regressions.
80 - If either fails, fix the issues before finishing.
81
82CONSTRAINTS:
83- Do not introduce any new runtime dependencies.
84- Do not change the component's visual output or behaviour — this is a types-only
85 refactor (the rendered DOM should be identical).
86- Do not use `as any`, `@ts-ignore`, or `@ts-expect-error` to silence errors.
87 Every type error must be resolved properly.
88- Preserve all JSDoc comments and add a brief doc comment to each union branch
89 explaining when it applies.

That is the entire instruction. It is long because precision matters — a coding agent with a clear spec will outperform a senior developer doing the same refactor by hand, and it will do it in minutes instead of hours.

Why This Works as an Agent Task

This refactoring is ideal for a coding agent because it is mechanically verifiable. The success criteria is not subjective — it is "does tsc --noEmit pass with zero errors and do all tests still pass?" The agent can run both checks, iterate on failures, and deliver a clean result. There is no design judgement required, no pixel-level review, no stakeholder alignment. It is pure type-level surgery with a binary outcome.

The instruction also forces the agent to do the analysis step first. This is critical. Without step 1 — reading all call sites and mapping the actual usage — the agent will invent union branches based on what looks plausible rather than what is real. The difference between a useful refactor and a mess is whether the union reflects how the component is actually consumed.

The DataTable refactoring at AXA — 45 props, 75 call sites, 27 files touched — is the kind of work that takes a senior developer a full day to execute carefully by hand. With a coding agent and the instruction above, the mechanical part (audit call sites, build the union, propagate the discriminant, fix compile errors) takes minutes. The human judgement — choosing the discriminant, deciding which branch is the default, structuring the shared interfaces — is exactly what the instruction encodes up front.

Conclusion

Prop explosion is not inevitable. It is the predictable result of modelling component APIs as flat bags of optional properties. Discriminated unions offer a precise, zero-overhead alternative that makes illegal states unrepresentable, autocompletion useful, and refactoring safe.

The pattern is not new. It is not experimental. It requires no libraries, no runtime dependencies, no build plugins. It is TypeScript doing exactly what TypeScript was designed to do — encoding constraints so developers do not have to memorise them.

Start with one component. Pick the one with the most confused consumers — the one where developers keep passing props that get silently ignored, where two booleans encode three states, where the runtime is full of defensive guards against combinations the type system should have prevented. Paste the agent instruction above into Claude Code or Codex, point it at the directory, and watch the compile errors surface every illegal prop combination that has been hiding in your codebase. Fix them. Then decide if you want to keep going.

Kontaktaufnahme

z.B. Ludwig van Beethoven
z.B. ludwig@beethoven.co
Falls das eine Herausforderung anspricht, vor der Sie stehen, freue ich mich auf einen Austausch darüber, wie ich helfen kann.