Discriminated Union in TypeScript für State und API Response
1. Übersicht
Viele UI-Fehler entstehen durch Zustände, die niemals vorkommen sollten. Beispiel: isLoading ist true, aber error hat auch einen Wert. Oder data ist undefined, obwohl die Anfrage erfolgreich war.
Dieser Artikel zeigt dir, wie du Discriminated Union von TypeScript verwendest, um Lade-State und API-Response zu modellieren. Danach wirst du wissen, wie du:
- Union mit einem Unterscheidungsfeld definierst,
- den Compiler automatisch Type Narrowing durchführen lässt,
- gezwungen wirst, alle Fälle zu behandeln mit Exhaustiveness Checks,
- Response zur Laufzeit mit Zod validierst,
- Tests für State-Übergänge schreibst.
2. Das Problem mit dem “Feld-Beutel”
Das gewohnte Muster ist, alles in eine Schnittstelle mit optionalen Feldern zu packen:
interface OrderState {
isLoading: boolean;
error?: string;
order?: { id: string; total: number };
}
const state: OrderState = {
isLoading: true,
error: "Timeout",
order: { id: "A1", total: 120 },
};
Der obige Code kompiliert problemlos. Aber er beschreibt einen sinnlosen Zustand: gleichzeitig wird geladen, es gibt einen Fehler und es gibt Daten.
Mit drei Feldern gibt es 2 × 2 × 2 = 8 Kombinationen. Tatsächlich sind nur etwa 4 davon gültig. Beim Lesen des State musst du überall state.order?.total oder state.order! schreiben. Der Compiler kann dir nicht helfen.
3. Was ist Discriminated Union?
Discriminated Union ist eine Union mehrerer Object-Typen. Jeder Typ hat ein gemeinsames Feld mit einem unterschiedlichen Literal Type. Dieses Feld heißt Discriminant oder “Tag”:
type Order = { id: string; total: number };
type OrderState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; order: Order }
| { status: "error"; message: string };
Hier ist status der Discriminant. Jeder Zweig deklariert nur die Felder, die für ihn sinnvoll sind. Der success-Zweig hat order, der error-Zweig hat message. Der Zustand “wird geladen, hat aber auch einen Fehler” kann nicht mehr dargestellt werden:
const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.
3.1. Narrowing nach dem Discriminant
Wenn du den Wert des Discriminant überprüfst, verengt TypeScript automatisch den Typ im entsprechenden Zweig:
function describe(state: OrderState): string {
if (state.status === "success") {
return `Bestellung ${state.order.id}: ${state.order.total}€`;
}
if (state.status === "error") {
return `Fehler: ${state.message}`;
}
return state.status === "loading" ? "Wird geladen..." : "Nicht gestartet";
}
console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
Bestellung A1: 120€
Fehler: Timeout
Innerhalb des if (state.status === "success")-Zweigs ist der Typ von state genau { status: "success"; order: Order }. Daher kannst du sofort auf state.order.id zugreifen, ohne ?. oder ! zu brauchen. Wenn du state.message in diesem Zweig eingeben würdest, würde der Compiler einen Fehler melden.
4. Exhaustiveness Check: Keinen Zustand vergessen
Union wird erst wirklich nützlich, wenn das Hinzufügen eines neuen Zweigs den Compiler überall auffordert, diesen Zweig zu behandeln. Der übliche Weg ist switch mit dem Typ never:
function assertNever(value: never): never {
throw new Error(`Unerwarteter Zustand: ${JSON.stringify(value)}`);
}
function render(state: OrderState): string {
switch (state.status) {
case "idle":
return "<button>Bestellung laden</button>";
case "loading":
return "<spinner />";
case "success":
return `<p>Summe: ${state.order.total}€</p>`;
case "error":
return `<p class="error">${state.message}</p>`;
default:
return assertNever(state);
}
}
Nach alle vier case-Blöcken ist der Typ von state im default-Fall never. Der Aufruf assertNever(state) ist daher gültig.
Angenommen, später fügst du { status: "cancelled" } zu OrderState hinzu, vergisst aber, render zu aktualisieren:
error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.
Der Fehler tritt beim Build auf, bevor der Nutzer einen weißen Bildschirm sieht. Wenn ungültige Daten trotzdem zur Laufzeit eindringen, beispielsweise von einer unzuverlässigen API, wirft assertNever einen aussagekräftigen Fehler statt stillschweigend undefined zurückzugeben.
Hinweis: Wenn du
"strict": trueaktivierst und einen expliziten Rückgabetyp deklarierst (: string), führt das Auslassen einercaseebenfalls zu dem Fehler “Function lacks ending return statement”. Dennoch istassertNeverwertvoll. Es gibt eine bessere Fehlermeldung und funktioniert auch mit Funktionen, dievoidzurückgeben.
5. API-Response modellieren
APIs geben normalerweise zwei Formen zurück: erfolgreich oder fehlgeschlagen. Du kannst einen gemeinsamen Result-Typ definieren:
type Result<T, E = ApiError> =
| { ok: true; data: T }
| { ok: false; error: E };
type ApiError =
| { kind: "network"; cause: string }
| { kind: "http"; status: number }
| { kind: "invalid"; issues: string[] };
Der Discriminant muss keine Zeichenkette sein. ok: true und ok: false sind zwei Literal Types von Boolean, also funktioniert Narrowing trotzdem. ApiError ist selbst eine verschachtelte Discriminated Union, unterschieden durch kind.
5.1. Response zur Laufzeit mit Zod validieren
TypeScript-Typen verschwinden nach dem Kompilieren. Wenn du (await res.json()) as Order schreibst, “versprichst” du dem Compiler nur etwas, überprüfst aber nichts. Um sicherzustellen, dass Daten vom Server die richtige Form haben, validiere mit Zod:
npm install zod
Angenommen, der Server gibt ein Payload mit Discriminant type zurück:
import { z } from "zod";
const OrderSchema = z.object({ id: z.string(), total: z.number() });
const OrderResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("found"), order: OrderSchema }),
z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);
type OrderResponse = z.infer<typeof OrderResponseSchema>;
z.discriminatedUnion("type", [...]) erstellt ein Schema basierend auf dem type-Feld. Zod liest den type-Wert zuerst, dann validiert es nach dem richtigen Zweig, also sind Fehlermeldungen präziser als bei z.union. z.infer erzeugt den entsprechenden TypeScript-Typ, daher musst du ihn nicht zweimal schreiben.
Dann die API-Aufrufs-Funktion, die alle Fehler in Result wickelt:
async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
let res: Response;
try {
res = await fetch(`/api/orders/${id}`);
} catch (e) {
return { ok: false, error: { kind: "network", cause: String(e) } };
}
if (!res.ok) {
return { ok: false, error: { kind: "http", status: res.status } };
}
const parsed = OrderResponseSchema.safeParse(await res.json());
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
return { ok: false, error: { kind: "invalid", issues } };
}
return { ok: true, data: parsed.data };
}
Diese Funktion wirft nie einen Fehler nach außen. Jedes Szenario wird im Rückgabetyp ausgedrückt. safeParse selbst gibt auch eine Discriminated Union mit Discriminant success zurück. Das ist genau das Pattern, das du lernst.
Die aufrufende Seite behandelt das Ergebnis wie folgt:
async function showOrder(id: string): Promise<string> {
const result = await fetchOrder(id);
if (!result.ok) {
switch (result.error.kind) {
case "network": return "Verbindung unterbrochen, versuche später.";
case "http": return `Server antwortet mit ${result.error.status}.`;
case "invalid": return "Daten ungültig.";
default: return assertNever(result.error);
}
}
return result.data.type === "found"
? `Summe: ${result.data.order.total}€`
: `Bestellung ${result.data.orderId} nicht gefunden`;
}
Nach if (!result.ok) { ... } weiß TypeScript, dass result garantiert der ok: true-Zweig ist. Daher kann auf result.data direkt zugegriffen werden.
6. State mit Reducer transformieren
Discriminated Union passt perfekt zu Reducer, etwa useReducer von React oder ein selbstgeschriebener Store. Sowohl State als auch Action sind Union:
type OrderAction =
| { type: "FETCH" }
| { type: "RESOLVE"; order: Order }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "FETCH":
return state.status === "loading" ? state : { status: "loading" };
case "RESOLVE":
return state.status === "loading"
? { status: "success", order: action.order }
: state;
case "REJECT":
return state.status === "loading"
? { status: "error", message: action.message }
: state;
case "RESET":
return { status: "idle" };
default:
return assertNever(action);
}
}
Dieser Reducer kodiert auch die State-Übergänge. RESOLVE funktioniert nur, wenn gerade loading. Dadurch wird eine verspätete Response ignoriert, nachdem der Nutzer RESET gedrückt hat. Du brauchst keine zusätzlichen Flags.
7. Tests für das Kernverhalten schreiben
Der Artikel verwendet Vitest. Die expect-Syntax ähnelt AssertJ und Jest:
npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";
describe("orderReducer", () => {
const order = { id: "A1", total: 120 };
it("wechselt von loading zu success bei RESOLVE", () => {
const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "success", order });
});
it("ignoriert verspätete RESOLVE wenn idle", () => {
const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "idle" });
});
it("render zeigt Fehlermeldung an", () => {
expect(render({ status: "error", message: "Timeout" }))
.toBe('<p class="error">Timeout</p>');
});
});
npx vitest run
✓ order.test.ts (3 tests)
✓ orderReducer > wechselt von loading zu success bei RESOLVE
✓ orderReducer > ignoriert verspätete RESOLVE wenn idle
✓ orderReducer > render zeigt Fehlermeldung an
Test Files 1 passed (1)
Tests 3 passed (3)
Der zweite Test ist der wichtigste. Er fixiert das Race-Condition-Schutz-Verhalten, eine Art Fehler, die das “Feld-Beutel”-Modell leicht hat.
8. Häufige Fehler und wann man Union nicht verwenden sollte
Discriminant wird zu string verallgemeinert. Wenn du ein Object in einer Zwischenvariablen erstellst, kann TypeScript string statt Literal inferieren:
const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.
Du hast drei Möglichkeiten: Gib den Typ sofort an (const s: OrderState = ...), nutze as const, oder verwende satisfies OrderState.
Frühes Destructuring bricht den Zusammenhang. Wenn du let { status, ...rest } = state schreibst, weiß TypeScript nicht mehr, zu welchem Zweig rest gehört, wenn du status überprüfst. Narrowing nach Destructuring funktioniert nur, wenn die Variable mit const deklariert ist und nicht neu zugewiesen wird.
Nutze in-Operator statt Discriminant. "order" in state funktioniert zum Narrowing. Aber dieser Weg ist fehleranfällig, wenn zwei Zweige denselben Feldnamen haben. Überprüfe immer über den Discriminant.
Discriminant optional oder änderbar. Schreib nicht status?: .... Ändere auch nicht state.status = "error" auf einem bestehenden Object. Erstelle stattdessen ein neues Object für jeden State. Du kannst das Feld als readonly markieren, damit der Compiler die Regel durchsetzt.
Typ-Umwandlung statt Validierung. as OrderResponse für Netzwerkdaten löscht alle Vorteile. An Systemgrenzen musst du Daten zur Laufzeit validieren.
Hinweis: Union solltest du nicht verwenden, wenn Felder wirklich unabhängig sind. Beispiel: Ein Formular hat
email?,phone?,note?, die in jeder Kombination ausgefüllt sein können. Hier ist optional korrekt. Erzwinge Union nur, um State-Explosion zu erleben ohne Sicherheitsgewinn.
9. Fazit
Discriminated Union verwandelt ungültige State-Kombinationen in Compiler-Fehler. Du hast gesehen, wie du:
- Union mit einem Literal-Discriminant definierst,
- TypeScript automatisch nach dem Discriminant narrowing lässt,
assertNevernutzt um alle Zweige zu behandeln,- API-Response mit
Resultund Zod-Validierung modellierst, - dasselbe Pattern für Reducer anwendest und mit Tests überprüfst.
Die kurze Regel: Wenn Felder nur zusammen sinnvoll sind, pack sie in den gleichen Union-Zweig. Wenn du nächstes Mal isLoading neben error? und data? schreiben willst, versuch stattdessen ein status-Feld.