Zustände typsicher statt Boolean-Flag-Soup
Wer Anwendungszustand mit mehreren unabhängigen Boolean-Flags modelliert, riskiert widersprüchliche Zustände zur Laufzeit, etwa gleichzeitig geladen und fehlgeschlagen. Discriminated Unions lösen dieses Problem, indem ein gemeinsames Tag-Feld TypeScript erlaubt, den exakten Zustand automatisch zu erkennen, sodass unmögliche Kombinationen bereits beim Kompilieren ausgeschlossen werden und der Editor nur die tatsächlich gültigen Felder vorschlägt.
Inhaltsverzeichnis
- 1. Boolean-Flag-Soup: Das Problem mit unabhängigen Zustandsflags
- 2. Der Diskriminant: Ein gemeinsames Tag-Feld als Kompass
- 3. Narrowing: Wie TypeScript den Typ automatisch verengt
- 4. Praxisbeispiel: Ein asynchroner Datenabruf als State-Machine
- 5. Exhaustiveness Checking mit dem never-Typ
- 6. Discriminated Unions in Reducer- und Action-Patterns
- 7. Verschachtelung und Kombination mehrerer Discriminated Unions
- 8. Praxiseinsatz: API-Responses, Formularstatus, UI State Machines
- 9. Boolean-Flags vs. Discriminated Unions im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Boolean-Flag-Soup: Das Problem mit unabhängigen Zustandsflags
In vielen React- und Vue-Anwendungen beginnt State-Modellierung mit unabhängigen Boolean-Flags: isLoading, isError, isSuccess, dazu optionale Felder wie data und error. Auf den ersten Blick wirkt das pragmatisch, doch jedes dieser Flags kann unabhängig von den anderen gesetzt werden. Nichts im Typsystem verhindert, dass isLoading und isError gleichzeitig true sind, obwohl dieser Zustand fachlich unmöglich ist. Genau dieses Muster nennt man Boolean-Flag-Soup: eine wachsende Anzahl loser Flags, deren gültige Kombinationen nur in den Köpfen der Entwickler existieren, nicht im Code.
Die Folgen zeigen sich meist erst zur Laufzeit. Ein vergessener Reset von isError beim erneuten Laden führt dazu, dass Spinner und Fehlermeldung gleichzeitig sichtbar sind. Ein data-Feld, das theoretisch immer optional ist, zwingt jede Komponente zu einem Null-Check, selbst wenn isSuccess längst true ist. Mit jedem weiteren Flag wächst die Anzahl der theoretisch möglichen Kombinationen exponentiell, während nur eine Handvoll davon tatsächlich sinnvoll ist. TypeScript kann hier kaum helfen, weil die Flags als unabhängige Properties deklariert sind und keine strukturelle Beziehung zueinander haben.
2. Der Diskriminant: Ein gemeinsames Tag-Feld als Kompass
Der Ausweg aus der Flag-Soup ist ein einziges gemeinsames Tag-Feld, der Diskriminant, meist status, type oder kind genannt. Statt mehrerer unabhängiger Booleans definiert man eine Union aus mehreren Objekttypen, wobei jeder Typ einen eigenen Literalwert für dieses Feld trägt und nur die Felder enthält, die in diesem Zustand tatsächlich existieren. Der loading-Zustand hat kein data-Feld, der error-Zustand hat kein data-Feld, nur success trägt die eigentlichen Nutzdaten.
Entscheidend ist, dass der Diskriminant ein Literaltyp sein muss, also die konkrete Zeichenkette "success" statt des allgemeinen Typs string. Nur Literaltypen erlauben es TypeScript, beim Vergleich mit einem konkreten Wert die Menge der noch möglichen Union-Mitglieder einzugrenzen. Diese Struktur macht unmögliche Zustände buchstäblich nicht mehr darstellbar: Es gibt kein Objektliteral, das gleichzeitig status: "loading" und ein data-Feld besitzt, weil der Typ es schlicht nicht erlaubt. Der Compiler wird so zum Wächter über die fachliche Konsistenz des Zustands, nicht erst ein Code-Review oder ein Testfall.
// A discriminated union: each variant carries a literal "status" tag
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
// The tag field must be a literal type, not a plain string,
// otherwise TypeScript cannot narrow the union at all.
function describeState<T>(state: RequestState<T>): string {
// Accessing state.data here would be a compile error,
// because "data" only exists on the "success" variant.
return `Current status: ${state.status}`;
}
3. Narrowing: Wie TypeScript den Typ automatisch verengt
Das eigentliche Comfort-Feature der Discriminated Unions ist das automatische Narrowing durch die Control-Flow-Analyse von TypeScript. Sobald der Compiler innerhalb eines switch-Statements oder eines if-Blocks eine Bedingung wie state.status === "success" oder einen case "success" sieht, schränkt er den Typ der Variable innerhalb dieses Zweigs exakt auf das passende Union-Mitglied ein. Innerhalb des case-Blocks kennt TypeScript dann sämtliche Felder dieses Zweigs, inklusive korrekter Typen, ganz ohne Type Assertion oder manuelles Casting.
Diese Analyse funktioniert nicht nur mit switch, sondern auch mit einfachen if-else-Ketten, mit logischen Operatoren wie && und sogar mit benutzerdefinierten Type Guards über das result is Foo-Muster. Wichtig ist, dass die Verengung an die lexikalische Blockstruktur gebunden ist: Wird die Variable zwischendurch neu zugewiesen oder in eine Closure übergeben, kann TypeScript die Narrowing-Information verlieren. In der Praxis bedeutet das: Zustand möglichst lokal auswerten und nicht über mehrere Funktionsebenen destrukturieren, bevor der Tag geprüft wurde.
type PaymentResult =
| { kind: "approved"; transactionId: string }
| { kind: "declined"; reason: string }
| { kind: "pending"; retryAfterMs: number };
function renderPaymentResult(result: PaymentResult): string {
// Inside each case, TypeScript narrows the union based on "kind"
switch (result.kind) {
case "approved":
// result is narrowed to { kind: "approved"; transactionId: string }
return `Payment approved, id ${result.transactionId}`;
case "declined":
// result is narrowed to { kind: "declined"; reason: string }
return `Payment declined: ${result.reason}`;
case "pending":
// result is narrowed to { kind: "pending"; retryAfterMs: number }
return `Retry in ${result.retryAfterMs}ms`;
}
}
function isDeclined(result: PaymentResult): result is Extract<PaymentResult, { kind: "declined" }> {
// A simple if-check on the tag also narrows within the block
return result.kind === "declined";
}
4. Praxisbeispiel: Ein asynchroner Datenabruf als State-Machine
Der klassische Anwendungsfall für Discriminated Unions ist ein asynchroner Datenabruf. Statt isLoading, isError und data getrennt zu verwalten, modelliert man vier klar getrennte Zustände: idle, loading, success mit den geladenen Daten und error mit dem aufgetretenen Fehler. Jede Komponente, die diesen Zustand konsumiert, muss sich nur noch fragen, welcher der vier Zustände gerade aktiv ist, nicht mehr, welche Kombination von Flags gerade zufällig gesetzt ist.
Der Vorteil zeigt sich besonders in React-Komponenten: Eine Kette aus if-Abfragen auf state.status rendert exakt eine UI pro Zustand, und der Editor schlägt in jedem Zweig automatisch nur die tatsächlich vorhandenen Felder vor. Ein Zugriff auf state.data im loading-Zweig wird sofort als Compile-Fehler markiert, lange bevor der Code in Produktion läuft. Dieses Muster lässt sich nahtlos mit useReducer, State-Management-Bibliotheken oder eigenen Custom Hooks kombinieren, weil der State selbst unabhängig vom Rendering-Framework bleibt.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useProductFetch(productId: string): FetchState<Product> {
const [state, setState] = useState<FetchState<Product>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetchProduct(productId)
.then((data) => {
if (!cancelled) setState({ status: "success", data });
})
.catch((error: Error) => {
if (!cancelled) setState({ status: "error", error });
});
return () => {
cancelled = true;
};
}, [productId]);
return state;
}
function ProductView({ productId }: { productId: string }) {
const state = useProductFetch(productId);
// Only one branch can ever render, no contradictory flags possible
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorBanner message={state.error.message} />;
if (state.status === "success") return <ProductCard product={state.data} />;
return <IdlePlaceholder />;
}
5. Exhaustiveness Checking mit dem never-Typ
Discriminated Unions entfalten ihr volles Potenzial erst in Kombination mit Exhaustiveness Checking. Ein switch-Statement über den Diskriminanten sollte immer einen default-Zweig besitzen, der die verbleibende Variable dem Typ never zuweist. Solange alle Fälle der Union behandelt wurden, ist die Variable an dieser Stelle tatsächlich vom Typ never, weil kein Union-Mitglied mehr übrig ist. Wird der Union später ein neuer Zustand hinzugefügt, etwa ein zusätzlicher retry-Status, ohne dass der switch angepasst wird, meldet der Compiler an genau dieser Stelle einen Typfehler.
Die gängige Implementierung ist eine kleine Hilfsfunktion assertNever(value: never), die zur Laufzeit eine Exception wirft und zur Kompilierzeit als Typ-Wächter dient. Dieses Muster verwandelt eine potenzielle Laufzeitlücke, einen vergessenen case, in einen sofort sichtbaren Build-Fehler. Gerade in großen Codebasen mit vielen Konsumenten eines State-Typs ist das ein erheblicher Sicherheitsgewinn, weil neue Varianten nicht mehr stillschweigend ignoriert werden können, sondern jeden betroffenen switch zum Anfassen zwingen.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
// Helper that only compiles if it is truly unreachable
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// If a new Shape variant is added and not handled above,
// "shape" is no longer "never" here and the build fails.
return assertNever(shape);
}
}
6. Discriminated Unions in Reducer- und Action-Patterns
Redux, useReducer und vergleichbare State-Container basieren strukturell bereits auf Discriminated Unions, auch wenn das selten explizit benannt wird. Eine Action ist eine Union aus mehreren Objekttypen mit einem gemeinsamen type-Feld als Diskriminant, und der Reducer ist im Kern ein switch-Statement über genau dieses Feld. Innerhalb jedes case-Zweigs kennt TypeScript exakt die Payload-Felder dieser einen Action, sodass ein Zugriff auf ein Feld, das nur bei einer anderen Action existiert, direkt als Fehler markiert wird.
Der Vorteil gegenüber lose typisierten Action-Objekten mit optionalen Payload-Feldern ist enorm: Ohne Discriminated Union müsste jeder Reducer-Zweig defensiv auf undefined prüfen, weil das Typsystem nicht garantieren kann, welches Feld bei welchem type tatsächlich vorhanden ist. Mit einer sauber diskriminierten Action-Union entfällt dieser Aufwand vollständig, und Exhaustiveness Checking stellt zusätzlich sicher, dass eine neu eingeführte Action niemals unbehandelt durchrutscht. Dieses Zusammenspiel ist einer der Hauptgründe, warum typisierte Redux- und useReducer-Codebasen in der Praxis auffällig wenige Laufzeitfehler im State-Handling produzieren.
type CartAction =
| { type: "ADD_ITEM"; sku: string; quantity: number }
| { type: "REMOVE_ITEM"; sku: string }
| { type: "SET_QUANTITY"; sku: string; quantity: number }
| { type: "CLEAR_CART" };
interface CartState {
items: Record<string, number>;
}
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
// action is narrowed to the ADD_ITEM variant, sku/quantity exist
const current = state.items[action.sku] ?? 0;
return { items: { ...state.items, [action.sku]: current + action.quantity } };
}
case "REMOVE_ITEM": {
const { [action.sku]: _removed, ...rest } = state.items;
return { items: rest };
}
case "SET_QUANTITY":
return { items: { ...state.items, [action.sku]: action.quantity } };
case "CLEAR_CART":
return { items: {} };
default:
return state;
}
}
7. Verschachtelung und Kombination mehrerer Discriminated Unions
Discriminated Unions lassen sich beliebig verschachteln, was besonders bei komplexeren Domänenmodellen relevant wird. Ein State-Objekt kann selbst ein Feld enthalten, das wiederum eine eigene Discriminated Union ist, etwa ein Formularstatus, der innerhalb seines valid-Zweigs zusätzlich zwischen verschiedenen Submission-Zuständen unterscheidet. TypeScript verengt in solchen Fällen Schritt für Schritt: zuerst über den äußeren Diskriminanten, dann, sobald man innerhalb dieses Zweigs auf den inneren Diskriminanten prüft, zusätzlich über die innere Union.
Auch das Kombinieren zweier unabhängiger Unions über einen zusammengesetzten Typ ist möglich, etwa wenn ein Netzwerkstatus und ein Berechtigungsstatus gemeinsam den sichtbaren UI-Zustand bestimmen. Hier lohnt sich Vorsicht: Werden zwei Unions mit je vier Zuständen naiv gekreuzt, entstehen theoretisch sechzehn Kombinationen, von denen oft nur wenige fachlich sinnvoll sind. In solchen Fällen ist es meist sauberer, einen einzigen, expliziten Diskriminanten für den kombinierten Zustand zu definieren, statt zwei Unions ungeprüft im selben Objekt zu vermischen und die Kombinatorik auf die Konsumenten abzuwälzen.
8. Praxiseinsatz: API-Responses, Formularstatus, UI State Machines
In der Praxis tauchen Discriminated Unions in fast jeder nicht-trivialen TypeScript-Codebasis auf, sobald man einmal gezielt danach sucht. API-Response-Envelopes profitieren besonders: Ein Backend, das entweder { ok: true; data: T } oder { ok: false; error: ApiError } zurückgibt, lässt sich verlustfrei als Union modellieren und zwingt jeden Call-Site-Code, den Fehlerfall explizit zu behandeln, statt data optimistisch als vorhanden anzunehmen.
Formularstatus ist ein weiteres Kernbeispiel: pristine, touched, validating und ein submitted-Zustand mit entweder success oder validationErrors lassen sich sauber als eine einzige Union abbilden, statt mit isDirty, isValidating und errors als lose Flags zu jonglieren. UI-State-Machines für Modals, Wizards oder Multi-Step-Formulare profitieren ebenso: Ein Modal-Zustand mit closed sowie open mit einem mode-Feld für create oder edit macht auf einen Blick klar, welche Props in welchem Modus überhaupt existieren. Auch WebSocket-Verbindungsstatus, Datei-Upload-Fortschritt und Feature-Flag-Auswertungen lassen sich nach demselben Muster deutlich robuster modellieren als mit klassischen Flag-Kombinationen.
9. Boolean-Flags vs. Discriminated Unions im Vergleich
Der Unterschied zwischen Boolean-Flag-Soup und Discriminated Unions lässt sich an wenigen konkreten Kriterien festmachen. Die folgende Tabelle stellt beide Ansätze für die typischen Problemstellen gegenüber.
| Kriterium | Boolean-Flags | Discriminated Union |
|---|---|---|
| Loading-Zustand | isLoading als separates Flag, unabhängig von data | Eigener loading-Zustand ohne data-Feld |
| Fehlerbehandlung | error meist optional, überall Null-Checks nötig | error nur im error-Zweig vorhanden, kein Null-Check |
| Unmögliche Zustände | isLoading und isError können gleichzeitig true sein | Struktur schließt widersprüchliche Zustände aus |
| Exhaustiveness bei switch | Kein Mechanismus, fehlende Fälle bleiben unbemerkt | never-Fallback erzwingt Behandlung neuer Zustände |
| IDE-Autovervollständigung | Editor schlägt Felder aller Zustände zusammen vor | Editor schlägt nur Felder des aktuellen Zustands vor |
In der Summe verschiebt der Diskriminant die Verantwortung für Konsistenz vom Entwickler zum Compiler. Statt disziplinierter Konventionen, die in Code-Reviews durchgesetzt werden müssen, prüft TypeScript bei jedem Build automatisch, ob alle Zustände korrekt behandelt werden.
Mironsoft
TypeScript-Architektur, State-Management und typsichere Frontend-Entwicklung für Magento- und Hyvä-Projekte
Bereit für robustere State-Modellierung in eurem Frontend?
Wir überprüfen eure TypeScript-Codebasis auf Boolean-Flag-Soup, unsichere State-Modelle und fehlende Exhaustiveness Checks, und refactorn kritische Stellen zu sauber diskriminierten Unions, die Fehler bereits beim Kompilieren sichtbar machen.
TypeScript Code-Review
Analyse eurer State-Modelle auf Boolean-Flag-Soup und fehlende Discriminants
State-Management-Refactoring
Migration zu diskriminierten Unions in Reducern, Hooks und Stores
Typsichere Frontend-Architektur
Exhaustiveness Checks und strikte Typisierung fest in der CI-Pipeline verankert
10. Zusammenfassung
Discriminated Unions lösen das Kernproblem der Boolean-Flag-Soup, indem sie unabhängige Flags durch ein einziges, literales Tag-Feld ersetzen. TypeScripts Control-Flow-Analyse nutzt diesen Diskriminanten, um den Typ innerhalb von switch- und if-Blöcken automatisch zu verengen, sodass jeder Zweig nur die tatsächlich existierenden Felder kennt. Kombiniert mit Exhaustiveness Checking über den never-Typ wird jede neu hinzugefügte Variante automatisch erzwungen, an allen betroffenen Stellen behandelt zu werden, statt stillschweigend durchzurutschen.
Das Muster ist keine akademische Übung, sondern die strukturelle Grundlage von Redux-Reducern, Async-State-Hooks und robusten API-Response-Modellen. Wer State in TypeScript weiterhin mit mehreren unabhängigen Booleans modelliert, verschenkt einen erheblichen Teil der Typsicherheit, die der Compiler eigentlich bieten könnte. Der Umstieg auf Discriminated Unions erfordert selten einen großen Rewrite, meist reicht es, bestehende State-Interfaces schrittweise mit einem gemeinsamen Tag-Feld zu versehen und die Konsumenten entsprechend anzupassen.
Discriminated Unions - Das Wichtigste auf einen Blick
Ein Tag pro Zustand
Ein literales Feld wie status oder type ersetzt mehrere unabhängige Booleans.
Narrowing statt Type Casts
switch und if verengen den Typ automatisch, ganz ohne as oder manuelle Assertions.
Exhaustiveness mit never
Ein assertNever-Fallback macht vergessene Zustände zu Build-Fehlern statt Laufzeitbugs.
Reducer & verschachtelte Unions
Redux-Actions und komplexe Domänenmodelle profitieren vom selben Diskriminanten-Muster.