P

TypeScript Discriminated Unions als Design-System-Vertrag: Prop-Explosion in Komponentenbibliotheken loswerden

React internals and practical implications
TypeScript patterns for large codebases
TypeScript
·20 Min. Lesezeit
Prasath Soosaithasan
von Prasath Soosaithasan
TypeScript Discriminated Unions als Design-System-Vertrag: Prop-Explosion in Komponentenbibliotheken loswerden

Das Problem hat einen Namen

Jede Komponentenbibliothek von ausreichendem Alter entwickelt dieselbe Krankheit. Eine Button-Komponente beginnt mit fünf Props. Achtzehn Monate später hat sie siebenunddreißig, von denen die Hälfte sich gegenseitig ausschließt, und die einzige Dokumentation ist ein Slack-Thread von jemandem, der die Firma verlassen hat. Das ist Prop-Explosion, und es ist kein Komplexitätsproblem — es ist ein Design-Problem. Genauer gesagt ist es das Fehlen eines Vertrags auf Typebene, der festlegt, welche Kombinationen von Props tatsächlich zulässig sind.

Der Standardansatz — ein flaches Interface mit einer wachsenden Liste optionaler Properties — ist eine Lüge. Er sagt TypeScript, dass jede Kombination gültig ist. Er sagt den Konsumenten, dass sie den Quellcode lesen müssen, um herauszufinden, welche tatsächlich funktionieren. Und er sagt dem nächsten Entwickler, der die Komponente wartet, dass er auf sich allein gestellt ist.

Das Schlimmste ist nicht die Verwirrung. Es ist die Stille. Ein Entwickler übergibt actions={bulkActions} an eine Tabelle, bei der kein Auswahlmodus aktiviert ist. Das Prop wird akzeptiert. TypeScript ist zufrieden. ESLint ist zufrieden. Die Testsuite ist zufrieden. Die "Bulk Actions" erscheinen nie auf dem Bildschirm, weil sie ohne Auswahlmöglichkeit bedeutungslos sind — aber der Entwickler weiß das nicht. Er denkt, das Feature ist korrekt eingebunden. Ist es nicht. Nichts hat ihn darauf hingewiesen. Das ist kein hypothetisches Szenario. Es passierte in einem Produktivprojekt und blieb verborgen, bis jemand fragte, warum die Buttons für die "Bulk Actions" in einer bestimmten Ansicht fehlte.

Was Discriminated Unions Ihnen wirklich bringen

Eine diskriminierte Union in TypeScript ist eine Vereinigung von Objekttypen, die ein gemeinsames Feld mit Literal-Typ teilen — den Diskriminanten. Der Compiler nutzt dieses Feld, um den Typ einzugrenzen, was bedeutet, dass er in jedem Zweig genau weiß, welche Eigenschaften verfügbar sind. Das ist kein fortgeschrittenes Feature. Es ist seit TypeScript 2.0 stabil. Die Tatsache, dass die meisten Komponentenbibliotheken es immer noch nicht verwenden, ist aufschlussreich.

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

Mit dieser Typdefinition gibt TypeScript einen Fehler aus, wenn Sie href an einen action-Button oder loading an einen navigation-Button übergeben. Die ungültigen Zustände werden nicht nur vermieden — sie sind nicht darstellbar. Das ist das Kernprinzip: Falscher Code soll erst gar nicht kompilieren.

Beachten Sie, dass kind nicht variant ist. In den meisten Design-Systemen steuert variant das visuelle Styling — primary, secondary, danger, success. Der Diskriminant hier ist etwas anderes: Er kodiert den Verhaltensvertrag der Komponente. Ein action-Button verarbeitet Klicks. Ein navigation-Button rendert einen Anker. Ein icon-only-Button erfordert ein barrierefreies Label. Das sind unterschiedliche Komponenten unter demselben Namen — und der Diskriminant macht das explizit. Jede kind kann weiterhin jede variant für das Styling akzeptieren.

Kontrast zum flachen 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}

Dieses Interface erlaubt einen Button mit kind="icon-only", ohne ariaLabel, und einer loading-Prop, die die Icon-only-Variante stillschweigend ignoriert. Der Type-Checker gibt grünes Licht. Der Screenreader versagt. Das Accessibility-Audit findet es drei Monate später. Alle sind unglücklich.

Die Diskriminante ist der Vertrag

Die zentrale Erkenntnis ist, dass das Diskriminantenfeld nicht nur ein Mechanismus zur Typverengung ist — es ist ein Vertrag zwischen dem Design-System-Team und jedem Nutzer der Komponente. Wenn Sie kind: "navigation" wählen, entscheiden Sie sich für einen bestimmten Satz an Fähigkeiten und Verantwortlichkeiten. Das Typsystem setzt dies automatisch durch.

Das hat drei konkrete Vorteile:

  • Autovervollständigung wird zur Dokumentation. Nach der Eingabe von kind: "navigation" schlägt der Editor nur noch href und external vor. Der Entwickler muss weder Storybook konsultieren noch den Quellcode durchsuchen. Der Typ ist die API-Interface.
  • Refactoring ist sicher. Wird dem icon-only-Kind eine erforderliche Prop hinzugefügt, entstehen Kompilierfehler an jeder Aufrufstelle, die dieses Kind verwendet — und nur an diesen. Kein Schrotflinten-Refactoring.
  • Laufzeitvalidierung wird optional. Wenn das Typsystem bereits ungültige Kombinationen verhindert, können die defensiven if (kind === "navigation" && !href)-Prüfungen entfallen, die das Innere von Komponenten übersäen. Der Code wird kürzer und das Bundle kleiner.

Fallstudie: DataTable bei AXA in Winterthur — 45 Props, 75 Aufrufstellen, ein Refactoring

Theorie ist billig. Hier ist, was passiert ist, als wir dieses Pattern in einem echten Projekt ausgerollt haben: eine gemeinsam genutzte DataTable-Komponente in einem Nx-Monorepo bei AXA in Winterthur, verwendet an über 75 Stellen in zwei Anwendungen. Die Komponente akzeptierte ungefähr 45 optionale Props über ein einzelnes flaches Interface. Sie war die meistgenutzte Komponente im gesamten Design-System und ein Paradebeispiel für Prop-Explosion.

Vorher: Boolean-Chaos

Die flache Schnittstelle verwendete zwei boolesche Flags zur Steuerung des Auswahlverhaltens:

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}

Zwei Booleans. Drei sinnvolle Zustände (keine Auswahl, Einzel-, Mehrfachauswahl). Vier mögliche Boolean-Kombinationen — von denen eine (singleRowSelectionEnabled && multiRowSelectionEnabled) ein Widerspruch ist, den die Komponente mit einem stillen Runtime-Reset behandelte:

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

Nichts im Typsystem hinderte einen Konsumenten daran, diesen Zweig auszulösen. Nichts hinderte daran, actions an eine Tabelle ohne aktivierte Selektion zu übergeben. Nichts hinderte daran, onSingleRowSelection zusammen mit multiRowSelectionEnabled zu übergeben. Das flache Interface akzeptierte alle 45 Props als Sammelsurium und überließ es der Komponente, das Chaos zur Laufzeit zu entwirren.

Und hier ist das Szenario, das das Problem greifbar machte: Ein Entwickler im Team übergab actions={bulkActions} an eine DataTable, bei der kein Auswahlmodus aktiviert war. Er erwartete, dass Bulk-Action-Buttons erscheinen würden. Das taten sie nicht. Die Prop wurde stillschweigend akzeptiert — TypeScript beschwerte sich nicht, ESLint meldete nichts, die Tests liefen durch. Der Entwickler verbrachte Zeit damit zu debuggen, warum die Buttons fehlten, ohne jemals zu vermuten, dass die Prop selbst in diesem Kontext bedeutungslos war. Das flache Interface hatte ihm signalisiert, dass es eine gültige Prop sei. War es nicht.

Jede interne Komponente und jeder Hook musste zwei separate Booleans destrukturieren und verarbeiten:

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}

Darüber hinaus erweiterte das Props-Interface VariantProps aus dem CVA-Wrapper, wodurch interne Styling-Keys wie multiRowSelectionEnabled, hasRowSelection und showSelectedRowsActions in die öffentliche API gelangten — obwohl hasRowSelection immer zur Laufzeit berechnet und nie von Konsumenten gesetzt wurde.

Die Diskriminante bestimmen

Der erste Schritt — und der Schritt, den die meisten Entwickler überspringen — war die Überprüfung aller 75+ Consumer-Aufrufstellen. Die Überprüfung ergab eine saubere Verteilung:

  • ~50 Tabellen: Keine Auswahl. Keine Selection-Props übergeben.
  • ~20 Tabellen: Mehrzeilenauswahl. Verwendet onMultiRowSelection, actions, showSelectedRowsActions.
  • ~5 Tabellen: Einzelzeilenauswahl. Verwendet onSingleRowSelection.

Keine einzige Aufrufstelle verwendete beide Boolean-Flags gleichzeitig. Die zwei Booleans waren ein verkappter Diskriminant – sie kodierten genau drei sich gegenseitig ausschließende Modi. Die Lösung: ein einzelnes String-Literal-Feld, selectionMode: "none" | "single" | "multi".

Danach: Die Diskriminierte 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>

Vier erwähnenswerte Designentscheidungen:

  • selectionMode?: "none" — optional im Default-Branch. Die ca. 50 Tabellen ohne Selektion übergeben keinerlei Selection-Props. Da selectionMode bei NoSelectionProps optional ist, entfällt für sie jeglicher Migrationsaufwand. Die Komponente behandelt undefined gleichbedeutend mit "none".
  • SelectionBaseProps — gemeinsam für Single und Multi. Props wie getCanSelectRow, rowSelection und onRowSelectionChange sind für beide Selektionsmodi relevant. Sie wurden in ein gemeinsames Interface extrahiert, das beide Branches erweitern — ohne Duplizierung und ohne Einbußen bei der Typsicherheit.
  • ResolvedProps — interner flacher Typ für die Context-Kette. React Context und der TableProvider benötigen eine flache Struktur — eine Discriminated Union lässt sich nicht sauber in den Context spreaden. Am Provider-Rand wurde ein ResolvedProps-Typ eingeführt, der die Union zu einem einzelnen Objekt mit dem aufgelösten selectionMode-String zusammenführt. Dieser Typ ist intern — Konsumenten sehen ihn nie.
  • CVA-Entkopplung. Das alte extends VariantProps wurde aus dem öffentlichen Prop-Typ entfernt. Visuelle Varianten (compact, zebra) bleiben in BaseProps als explizite optionale Felder erhalten. Der variants-Aufruf von CVA lebt nun innerhalb der Komponente und wird mit den aufgelösten Props gespeist — nicht über die Consumer-API exponiert.

Die Komponentenimplementierung

Innerhalb der Komponente steuert der Diskriminant ein sauberes switch:

1// DataTable.tsx — AFTER (simplified)
2export function DataTable<TData extends RowData>(props: Props<TData>) {
3 const selectionMode = props.selectionMode ?? "none"
4
5 // Resolve to flat internal shape for context
6 const resolved: ResolvedProps<TData> = {
7 ...props,
8 selectionMode,
9 hasRowSelection: selectionMode !== "none",
10 }
11
12 return (
13 <TableProvider value={resolved}>
14 <TableContainer>
15 <TableHeader />
16 <TableBody />
17 {selectionMode === "multi" && props.showSelectedRowsActions && (
18 <SelectedRowsActions
19 actions={props.actions}
20 label={props.selectedRowsLabel}
21 />
22 )}
23 </TableContainer>
24 </TableProvider>
25 )
26}

Beachten Sie, dass auf props.actions nur innerhalb des selectionMode === "multi"-Zweigs zugegriffen wird. TypeScript weiß, dass dies sicher ist, weil die Union garantiert, dass actions nur auf MultiSelectionProps existiert. Außerhalb dieses Zweigs wäre props.actions ein Kompilierfehler. Das Typsystem übernimmt die Guard-Logik, die früher die alten Laufzeitprüfungen erledigt haben.

Interne Hooks — Vorher und Nachher

1// useRowSelection.ts — BEFORE
2export function useRowSelection(
3 table: Table<any>,
4 singleRowSelectionEnabled?: boolean,
5 multiRowSelectionEnabled?: boolean,
6) {
7 if (singleRowSelectionEnabled && multiRowSelectionEnabled) {
8 table.setRowSelection({}) // impossible state handled at runtime
9 }
10 // ...
11}
12
13// useRowSelection.ts — AFTER
14export function useRowSelection(
15 table: Table<any>,
16 selectionMode: "none" | "single" | "multi",
17) {
18 // impossible state is now unrepresentable — no guard needed
19 // ...
20}

Die Absicherung gegen unmögliche Zustände ist verschwunden. Nicht, weil jemand sie gelöscht und auf das Beste gehofft hat — sondern weil das Typsystem sie strukturell überflüssig gemacht hat. Ein String ersetzt zwei Booleans, die Funktionssignatur dokumentiert ihren eigenen Vertrag, und die Aufrufstelle kann keine widersprüchliche Kombination übergeben.

Migrationsergebnisse

Die Migration betraf 27 Dateien im gesamten Monorepo. Die Aufschlüsselung:

  • ~50 Tabellen (keine Auswahl): Keine Änderungen. Da selectionMode?: "none" optional ist, kompilierten diese Aufrufstellen ohne Anpassung.
  • ~20 Tabellen (Mehrfachauswahl): multiRowSelectionEnabled={true} durch selectionMode="multi" ersetzt. Mechanisches Suchen-und-Ersetzen.
  • ~5 Tabellen (Einzelauswahl): singleRowSelectionEnabled={true} durch selectionMode="single" ersetzt.
  • Interne Hooks und Kindkomponenten: Zwei boolesche Parameter durch einen String-Parameter ersetzt. Den Guard für unmögliche Zustände und den zugehörigen useEffect entfernt.

Der Kompilierungsschritt nach dem Refactoring deckte drei Aufrufstellen auf, die selektionsbezogene Props an Tabellen ohne aktivierte Selektion übergeben hatten — genau die Art von stillem Bug, die das flache Interface verborgen hatte. Alle drei wurden im selben PR behoben.

Der erschöpfende Switch: Ihr Sicherheitsnetz

Das switch-Statement mit einer vollständigen Prüfung ist das Implementierungsmuster, das diskriminierte Unions in Komponenten praktisch einsetzbar macht:

1function Button(props: ButtonProps) {
2 switch (props.kind) {
3 case "action":
4 return <button onClick={props.onClick}>{props.children}</button>
5 case "navigation":
6 return <a href={props.href}>{props.children}</a>
7 case "icon-only":
8 return <button aria-label={props.ariaLabel}>{props.icon}</button>
9 default: {
10 const _exhaustive: never = props;
11 return _exhaustive;
12 }
13 }
14}

Der default-Branch weist props dem Typ never zu. Wenn jemand der Union einen vierten Typ hinzufügt und vergisst, hier einen Case zu ergänzen, erzeugt TypeScript einen Kompilierfehler in dieser Zeile — keinen Laufzeitfehler in Produktion. Das ist der Unterschied zwischen „wir behandeln alle Fälle" und „wir behandeln nachweislich alle Fälle."

Storybook und Testing: Das satisfies-Pattern

Ein Bereich, in dem diskriminierte Unions Teams überraschen, ist Storybook. Story-Args sind typischerweise lose typisiert, was die Einschränkungen der Union umgehen kann. Die Lösung ist satisfies:

1// Button.stories.tsx
2import type { ComponentProps } from "react";
3import type { Meta, StoryObj } from "@storybook/react";
4import { Button } from "./Button";
5
6type ButtonProps = ComponentProps<typeof Button>;
7
8const meta: Meta<typeof Button> = {
9 component: Button,
10};
11export default meta;
12
13export const Action: StoryObj<typeof Button> = {
14 args: {
15 kind: "action",
16 onClick: () => console.log("clicked"),
17 loading: false,
18 } satisfies ButtonProps,
19};
20
21export const Navigation: StoryObj<typeof Button> = {
22 args: {
23 kind: "navigation",
24 href: "/dashboard",
25 external: false,
26 } satisfies ButtonProps,
27};

Ohne satisfies kann Storybooks lockere Typisierung dieselben ungültigen Kombinationen verbergen, die die Union verhindern soll. Test-Hilfsmittel wie render(<Button {...props} />) funktionieren identisch — das Typsystem fängt fehlerhafte Testdaten bereits zur Kompilierzeit ab.

Leistung und Auswirkungen auf die Bundle-Größe

Diskriminierte Unions sind ein Compile-Time-Konstrukt. Sie erzeugen keinerlei Laufzeit-Overhead. Das switch-Statement in der Komponentenimplementierung ist dieselbe Verzweigungslogik, die Sie ohnehin schreiben würden — die Union zwingt Sie lediglich dazu, jeden Zweig zu behandeln.

Wenn überhaupt, wird das Bundle kleiner. Laufzeit-Prop-Validierung (prop-types, manuelle Prüfungen, Invariant-Assertions) kann entfernt werden, weil das Typsystem die korrekte Struktur bereits zur Build-Zeit garantiert. Beim DataTable-Refactoring wurde ein kompletter Impossible-State-Guard samt zugehörigem useEffect gelöscht — kleine Einsparungen pro Komponente, aber sie summieren sich über eine Bibliothek mit dreißig Komponenten.

Skalierung zu einem vollständigen Design System

Einzelne Komponenten sind der einfache Gewinn. Der eigentliche Hebel entsteht, wenn sich die Diskriminante durch das System ausbreitet:

  • Theme-Tokens: Eine ColorScheme-Union, bei der jede Variante eigene Kontrastverhältnisse und barrierefreie Farbpaarungen mitbringt, wodurch WCAG-Verstöße bereits auf Typebene verhindert werden.
  • Layout-Primitive: Ein Stack, das bei direction: "horizontal" align, aber nicht wrap akzeptiert, und bei direction: "vertical" wrap, jedoch mit anderen align-Optionen.
  • Formularfelder: Eine Input-Union, bei der type: "select" options erfordert, type: "text" maxLength akzeptiert und type: "file" accept sowie multiple mitbringt.

Das Muster ist fraktal. Jede Ebene des Komponentenbaums kann ihren eigenen diskriminierten Vertrag durchsetzen, und die Einschränkungen lassen sich kombinieren. Ein FormField, das Input umschließt, kann dieselbe Union bereitstellen und so die Typsicherheit vom Seitenlayout bis hinunter zum DOM-Element bewahren.

Das kulturelle Argument

Das technische Argument ist eindeutig. Schwieriger ist die kulturelle Überzeugungsarbeit. Teams, die an any-lastige Codebasen oder lose Interfaces gewöhnt sind, empfinden Discriminated Unions als Reibung. Das Gegenargument ist einfach: Die Reibung ist nicht neu. Sie war schon immer da — in Slack-Fragen, in Code-Review-Kommentaren, in Produktionsfehlern, in der Einarbeitungszeit. Die Union verlagert die Reibung von der Laufzeit zur Kompilierzeit, von Menschen zu Maschinen, von teuer zu günstig.

Ein Design-System ist ein Vertrag zwischen dem Plattform-Team und jedem Produkt-Team, das es nutzt. Verträge sollten explizit, durchsetzbar und lesbar sein. Eine flache Schnittstelle mit dreißig optionalen Props ist nichts davon. Eine diskriminierte Union ist alle drei.

Beim AXA-Projekt war die erste Reaktion des Teams vorhersehbar — „das ist umständlicher, warum können wir nicht einfach bei den Booleans bleiben?" Die Antwort kam innerhalb der ersten Woche: Ein Entwickler, der actions an einer Tabelle ohne Selektion verdrahtet hatte, bekam sofort einen Compile-Fehler statt eines stillen No-ops. Genau dieser eine Moment — das Typsystem sagte ihm exakt, was falsch war und warum — überzeugte die Skeptiker schneller, als es jede Präsentation gekonnt hätte.

Schluss mit manuellem Refactoring — Überlassen Sie es Ihrem Coding-Agenten

Alles oben ist die Theorie und der Praxisbeweis. Hier kommt der praktische Teil: 2026 sollten Sie dieses Refactoring nicht mehr von Hand machen. Das ist genau die Art mechanischer, regelbasierter Transformation, die ein Coding-Agent — Claude Code, Codex, Cursor, was auch immer Sie nutzen — gut beherrscht, solange Sie ihm eine präzise Anweisung geben. Vage Prompts liefern vage Ergebnisse. Eine klare Spezifikation liefert ein sauberes Refactoring.

Unten finden Sie eine Copy-Paste-Anleitung, die Sie direkt an Ihren Coding-Agent weitergeben können. Zeigen Sie ihm ein Komponentenverzeichnis, und er erledigt die Migration für Sie.

Die Anweisung

Kopieren Sie diesen gesamten Block. Fügen Sie ihn in Ihren Coding-Agent ein. Ersetzen Sie den Pfad in der ersten Zeile.

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.

Das ist die gesamte Anweisung. Sie ist lang, weil Präzision wichtig ist – ein Coding-Agent mit einer klaren Spezifikation wird einen Senior-Entwickler übertreffen, der dasselbe Refactoring von Hand durchführt, und er wird es in Minuten statt in Stunden erledigen.

Warum das als Agent-Aufgabe funktioniert

Dieses Refactoring ist ideal für einen Coding-Agenten, weil es mechanisch überprüfbar ist. Das Erfolgskriterium ist nicht subjektiv — es lautet: „Läuft tsc --noEmit ohne Fehler durch und bestehen alle Tests weiterhin?" Der Agent kann beide Prüfungen ausführen, bei Fehlern iterieren und ein sauberes Ergebnis liefern. Es ist kein gestalterisches Urteil nötig, kein pixelgenaues Review, keine Abstimmung mit Stakeholdern. Es ist reine Typ-Chirurgie mit binärem Ergebnis.

Die Anweisung zwingt den Agenten außerdem, zuerst den Analyseschritt durchzuführen. Das ist entscheidend. Ohne Schritt 1 — alle Aufrufstellen lesen und die tatsächliche Nutzung erfassen — erfindet der Agent Union-Varianten basierend auf dem, was plausibel aussieht, statt auf dem, was real ist. Der Unterschied zwischen einem nützlichen Refactoring und einem Chaos liegt darin, ob die Union widerspiegelt, wie die Komponente tatsächlich verwendet wird.

Das DataTable-Refactoring bei AXA — 45 Props, 75 Aufrufstellen, 27 betroffene Dateien — ist die Art von Arbeit, für die ein Senior-Entwickler einen ganzen Tag braucht, um sie sorgfältig von Hand durchzuführen. Mit einem Coding-Agenten und der obigen Anweisung dauert der mechanische Teil (Aufrufstellen prüfen, die Union bauen, den Diskriminator propagieren, Kompilierfehler beheben) nur Minuten. Das menschliche Urteilsvermögen — die Wahl des Diskriminators, die Entscheidung, welcher Branch der Standard ist, die Strukturierung der gemeinsamen Interfaces — ist genau das, was die Anweisung vorab kodiert.

Fazit

Prop-Explosion ist nicht unvermeidlich. Sie ist das vorhersehbare Ergebnis davon, Komponenten-APIs als flache Sammlungen optionaler Properties zu modellieren. Discriminated Unions bieten eine präzise, aufwandsfreie Alternative, die ungültige Zustände nicht darstellbar macht, Autovervollständigung nützlich und Refactoring sicher.

Das Muster ist nicht neu. Es ist nicht experimentell. Es erfordert keine Bibliotheken, keine Laufzeitabhängigkeiten, keine Build-Plugins. Es ist TypeScript, das genau das tut, wofür TypeScript entwickelt wurde — Einschränkungen zu codieren, damit Entwickler sie sich nicht merken müssen.

Beginnen Sie mit einer Komponente. Nehmen Sie die, bei der die Nutzer am meisten verwirrt sind — die, bei der Entwickler ständig Props übergeben, die stillschweigend ignoriert werden, bei der zwei Booleans drei Zustände codieren, bei der die Laufzeit voller defensiver Absicherungen gegen Kombinationen ist, die das Typsystem eigentlich hätte verhindern sollen. Fügen Sie die obige Agenten-Anweisung in Claude Code oder Codex ein, richten Sie sie auf das Verzeichnis, und beobachten Sie, wie die Kompilierfehler jede illegale Prop-Kombination aufdecken, die sich in Ihrer Codebase versteckt hat. Behebe Sie sie. Dann entscheiden Sie, ob Sie weitermachen wollen.

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.