Error Handling mit Discriminated Unions in TypeScript
AI generated
<T>
type
TypeScript · Fehlerbehandlung · Typsystem
Error Handling mit Discriminated Unions
Fehlerzustaende exhaustiv statt lueckenhaft modellieren

Ein Fehlercode als Zahl oder ein einzelnes Boolean fuer den Fehlerfall verschweigt dem Compiler, welche Fehlerarten tatsaechlich existieren. Discriminated Unions machen jede moegliche Fehlervariante als eigenen, benannten Typ sichtbar und erzwingen ueber Exhaustiveness Checking, dass keine davon im Code vergessen wird.

17 Min. Lesezeit Discriminated Unions · switch · never · Exhaustiveness TypeScript 5.x

1. Warum generische Fehlercodes im State Management scheitern

Viele Codebasen behandeln Fehlerzustaende noch immer als lose Kombination aus einem Boolean und einer optionalen Zeichenkette: { hasError: boolean, errorMessage?: string }. Dieses Muster funktioniert oberflaechlich, verschweigt dem Typsystem aber alles Wesentliche. Der Compiler weiss nicht, welche Fehlerarten ueberhaupt existieren, ob ein Netzwerkfehler anders behandelt werden muss als ein Validierungsfehler, und ob im Fehlerfall weitere Felder verfuegbar sind. Discriminated Unions loesen genau dieses Problem, indem jede Fehlerart als eigene, klar benannte Variante im Typsystem existiert.

Ein Discriminated Union fuer Fehlerbehandlung besteht aus mehreren Objekttypen, die ein gemeinsames Literal Feld teilen, haeufig type oder kind genannt. Jede Variante kann zusaetzliche, nur fuer sie relevante Felder tragen. Ein Netzwerkfehler braucht vielleicht einen HTTP Statuscode, ein Validierungsfehler braucht den Namen des betroffenen Feldes. Ohne Discriminated Union muessten alle diese Felder optional in einem einzigen flachen Objekt existieren, was zu unsinnigen Zustaenden fuehrt, etwa einem HTTP Statuscode bei einem reinen Validierungsfehler.

Der zweite grosse Vorteil zeigt sich erst bei der Verarbeitung: TypeScript kann bei einer echten Discriminated Union pruefen, ob wirklich alle Varianten behandelt wurden. Dieses als Exhaustiveness Checking bekannte Feature ist bei losen Fehlercodes komplett unwirksam, weil der Compiler keine feste Menge moeglicher Werte kennt. Bei einer Discriminated Union hingegen erzeugt ein fehlender Fall im switch einen Compilerfehler, sobald eine neue Variante hinzugefuegt wird.

2. Discriminated Unions als Grundprinzip fuer Fehlerzustaende

Das Grundmuster einer Discriminated Union fuer Fehlerbehandlung ist einfach: eine Reihe von Objekttypen, jeweils mit einem gemeinsamen Literal Feld, das TypeScript als Diskriminator erkennt. Sobald dieses Feld in einer Bedingung geprueft wird, engt TypeScript den Typ innerhalb dieses Zweiges automatisch auf die passende Variante ein. Dieses Verhalten heisst Narrowing und ist der eigentliche Grund, warum Discriminated Unions so gut fuer Fehlerbehandlung geeignet sind: der Zugriff auf variantenspezifische Felder ist typsicher, ohne manuelle Type Assertions.

Ein zentrales Designprinzip ist, dass jede Fehlervariante genau die Felder tragen sollte, die fuer diese Fehlerart tatsaechlich sinnvoll sind, nicht mehr und nicht weniger. Ein NetworkError braucht statusCode und url, waehrend ein ValidationError stattdessen field und constraint braucht. Diese Praezision ist der Kernvorteil gegenueber einer generischen Fehlerklasse mit vielen optionalen Eigenschaften, bei der jede Konsumentenstelle selbst pruefen muss, welche Felder in welchem Fall gesetzt sind.

In der Praxis kombiniert man Discriminated Unions haeufig mit dem Result Type Pattern aus einem verwandten Artikel: Statt einer einzelnen Fehlerzeichenkette wird der Fehlertyp einer Result Union durch eine eigene, mehrere Varianten umfassende Discriminated Union ersetzt. So bleibt der Erfolgsfall unveraendert, waehrend der Fehlerfall selbst wieder eine eigene, exhaustive Struktur bekommt.


// A discriminated union of distinct, named error variants
type AppError =
  | { type: "network"; statusCode: number; url: string }
  | { type: "validation"; field: string; constraint: string }
  | { type: "notFound"; resourceId: string }
  | { type: "unauthorized"; requiredRole: string };

function describeError(error: AppError): string {
  switch (error.type) {
    case "network":
      // narrowed: statusCode and url are available here
      return `Network error ${error.statusCode} calling ${error.url}`;
    case "validation":
      return `Field "${error.field}" violates constraint "${error.constraint}"`;
    case "notFound":
      return `Resource "${error.resourceId}" was not found`;
    case "unauthorized":
      return `Missing role "${error.requiredRole}"`;
  }
}

const sample: AppError = { type: "validation", field: "email", constraint: "format" };
console.log(describeError(sample));

3. Einen Error Union Type fuer Formularvalidierung entwerfen

Formularvalidierung ist einer der haeufigsten Anwendungsfaelle fuer eine fehlerbezogene Discriminated Union, weil ein einzelnes Formular typischerweise mehrere unterschiedliche Fehlerarten gleichzeitig produzieren kann. Ein Pflichtfeld kann leer sein, ein Textfeld kann ein ungueltiges Format haben, ein Zahlenfeld kann ausserhalb eines erlaubten Bereichs liegen. Jede dieser Situationen verdient eine eigene Variante in der Fehler Union, damit die UI Schicht gezielt die passende Fehlermeldung anzeigen kann, statt eine generische Zeichenkette zu parsen.

Ein bewaehrtes Muster ist, die Fehler Union pro Feldtyp zu strukturieren und dann ein Array dieser Fehler pro Formular zu sammeln, statt nur einen einzigen Fehler zuzulassen. So kann die UI alle Probleme auf einmal anzeigen, statt den Benutzer durch mehrere Korrekturrunden zu schicken. Wichtig ist dabei, dass jede Variante der Discriminated Union auch das betroffene Feld enthaelt, damit die Zuordnung zur richtigen UI Komponente eindeutig bleibt.

Bibliotheken wie Zod erzeugen intern bereits aehnliche Strukturen und lassen sich gut mit einer eigenen, projektspezifischen Discriminated Union kombinieren: Das rohe Zod Fehlerobjekt wird in eine schlankere, fuer die eigene Anwendung passende Union uebersetzt, bevor es an die UI Schicht weitergereicht wird. Das entkoppelt die UI von der konkreten Validierungsbibliothek und macht einen spaeteren Wechsel einfacher.


// Field-level error union tailored to form validation
type FieldError =
  | { kind: "required"; field: string }
  | { kind: "tooShort"; field: string; minLength: number }
  | { kind: "outOfRange"; field: string; min: number; max: number }
  | { kind: "invalidFormat"; field: string; pattern: string };

function validateSignupForm(input: {
  username: string;
  age: number;
  email: string;
}): FieldError[] {
  const errors: FieldError[] = [];

  if (input.username.length === 0) {
    errors.push({ kind: "required", field: "username" });
  } else if (input.username.length < 3) {
    errors.push({ kind: "tooShort", field: "username", minLength: 3 });
  }

  if (input.age < 18 || input.age > 120) {
    errors.push({ kind: "outOfRange", field: "age", min: 18, max: 120 });
  }

  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
    errors.push({ kind: "invalidFormat", field: "email", pattern: "email" });
  }

  return errors;
}

function fieldErrorMessage(error: FieldError): string {
  switch (error.kind) {
    case "required":
      return `${error.field} is required`;
    case "tooShort":
      return `${error.field} must be at least ${error.minLength} characters`;
    case "outOfRange":
      return `${error.field} must be between ${error.min} and ${error.max}`;
    case "invalidFormat":
      return `${error.field} has an invalid format`;
  }
}

4. Exhaustiveness Checking mit switch und never

Der wichtigste praktische Vorteil einer Discriminated Union fuer Fehlerbehandlung ist Exhaustiveness Checking: Wenn spaeter eine neue Fehlervariante zur Union hinzugefuegt wird, aber vergessen wird, sie in einem bestehenden switch zu behandeln, meldet der Compiler einen Fehler, statt das Problem stillschweigend zur Laufzeit auftreten zu lassen. Dieser Mechanismus basiert auf dem never Type: Ein default Zweig, der den verbleibenden Typ einer Variable einer Funktion zuweist, die never erwartet, erzwingt, dass nach Behandlung aller Faelle nichts mehr uebrig bleibt.

Die Implementierung ist unauffaellig, aber wirkungsvoll: eine kleine Hilfsfunktion assertUnreachable(value: never): never wird im default Fall aufgerufen. Solange alle Varianten behandelt wurden, ist der Typ an dieser Stelle tatsaechlich never, und der Aufruf kompiliert. Wird eine neue Variante hinzugefuegt, aber kein zusaetzlicher case ergaenzt, bleibt an dieser Stelle die neue, unbehandelte Variante uebrig, was einen Typfehler erzeugt, lange bevor der Code in Produktion laeuft.

Dieses Muster ist besonders wertvoll in Teams mit mehreren Entwicklern, die unabhaengig voneinander an derselben Fehler Union arbeiten. Wer eine neue Fehlervariante einfuehrt, bekommt sofort eine Liste aller Stellen im Code, die diese neue Variante noch nicht behandeln, weil der Compiler an jeder dieser Stellen einen Fehler wirft. Ohne Exhaustiveness Checking wuerde ein solcher fehlender Fall erst durch manuelles Testen oder, schlimmer, durch einen Produktionsfehler auffallen.


// Exhaustiveness checking via a never-typed helper
function assertUnreachable(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}

type PaymentError =
  | { type: "insufficientFunds"; available: number; required: number }
  | { type: "cardDeclined"; reason: string }
  | { type: "gatewayTimeout" };

function handlePaymentError(error: PaymentError): string {
  switch (error.type) {
    case "insufficientFunds":
      return `Only ${error.available} available, ${error.required} required`;
    case "cardDeclined":
      return `Card declined: ${error.reason}`;
    case "gatewayTimeout":
      return "Payment gateway timed out, please retry";
    default:
      // If a new variant is added above without a matching case here,
      // TypeScript reports a compile error at this exact line
      return assertUnreachable(error);
  }
}

5. Discriminated Unions mit UI States kombinieren

Fehlerzustaende existieren selten isoliert, sondern sind Teil eines groesseren UI Zustands, der auch Laden, Erfolg und Leerzustand umfassen muss. Eine bewaehrte Erweiterung des Discriminated Union Musters ist, den gesamten Ladezustand einer Komponente als eine einzige Union zu modellieren, deren Fehlervariante wiederum die spezifische Error Union aus dem vorherigen Abschnitt enthaelt. So entsteht eine zusammenhaengende, exhaustive Zustandsmaschine statt mehrerer unabhaengiger Booleans wie isLoading, hasError und data.

Der entscheidende Vorteil gegenueber getrennten Booleans: Bei mehreren unabhaengigen Flags kann theoretisch isLoading und hasError gleichzeitig wahr sein, ein Zustand, der in der Anwendung meist keinen Sinn ergibt, aber vom Typsystem nicht ausgeschlossen wird. Eine einzige Discriminated Union mit den Varianten idle, loading, success und error schliesst solche unsinnigen Kombinationen strukturell aus, weil immer genau eine Variante aktiv ist.

In Alpine.js Komponenten innerhalb eines Hyvae Themes laesst sich dasselbe Muster mit einer schlanken Typdefinition fuer den x-data Zustand umsetzen, auch wenn Alpine.js selbst kein eigenes Typsystem mitbringt. Die Union wird als TypeScript Typ definiert, waehrend die eigentliche Logik als reines JavaScript in die Komponente kompiliert wird. Der Nutzen bleibt derselbe: Die Zustandsuebergaenge sind zur Entwicklungszeit exhaustiv geprueft, auch wenn Alpine.js zur Laufzeit keine Typen kennt.


// A unified UI state as a single discriminated union
type LoadState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: AppError };

function renderState<T>(state: LoadState<T>, render: (data: T) => string): string {
  switch (state.status) {
    case "idle":
      return "Nothing loaded yet";
    case "loading":
      return "Loading…";
    case "success":
      return render(state.data);
    case "error":
      return `Error: ${describeError(state.error)}`;
  }
}

6. Verschachtelte Error Unions fuer mehrstufige Prozesse

Mehrstufige Prozesse wie ein Checkout Ablauf oder ein Datenimport bestehen aus mehreren Schritten, von denen jeder eigene Fehlerarten produzieren kann. Eine flache Discriminated Union ueber alle moeglichen Fehler aller Schritte wird schnell unuebersichtlich. Besser ist eine verschachtelte Struktur: jeder Schritt hat seine eigene, kleine Error Union, und eine uebergeordnete Union kombiniert diese mit einem zusaetzlichen Feld, das angibt, in welchem Schritt der Fehler aufgetreten ist.

Dieses Muster nennt man manchmal eine getaggte Union von Unions: Die aeussere Variante traegt step als Diskriminator, waehrend die innere Variante die eigentliche Fehlerart innerhalb dieses Schritts beschreibt. Der Vorteil ist doppelte Exhaustiveness: TypeScript prueft sowohl, ob alle Schritte behandelt wurden, als auch, ob innerhalb jedes Schritts alle Fehlerarten behandelt wurden, wenn beide Ebenen konsequent mit switch und dem never Muster abgesichert werden.

In einem Magento GraphQL Checkout Flow etwa gibt es typischerweise die Schritte Warenkorbvalidierung, Adressvalidierung, Versandmethode und Zahlung. Jeder dieser Schritte hat vollstaendig unterschiedliche moegliche Fehlerarten. Eine verschachtelte Discriminated Union bildet diese Struktur eins zu eins ab, waehrend die UI Schicht anhand des aeusseren step Feldes entscheidet, welcher Abschnitt des Formulars die Fehlermeldung anzeigen soll.

7. Discriminated Unions versus Error Klassen

Eine haeufige Designfrage ist, ob Fehlerzustaende als Discriminated Union aus einfachen Objekten oder als Hierarchie von Error Klassen modelliert werden sollten. Beide Ansaetze haben ihre Berechtigung, loesen aber unterschiedliche Probleme. Eine Discriminated Union ist ideal, wenn Fehler als reine Daten behandelt werden sollen, etwa um sie ueber eine Netzwerkgrenze zu serialisieren, in einem Redux artigen Store zu speichern, oder in Tests einfach mit toEqual zu vergleichen.

Error Klassen hingegen eignen sich besser, wenn Verhalten an den Fehler gebunden werden soll, etwa eine retry() Methode oder eine toHttpResponse() Methode, und wenn die native JavaScript Stacktrace Erfassung ueber Error.captureStackTrace genutzt werden soll. Der Nachteil von Klassen: instanceof Pruefungen funktionieren nicht zuverlaessig ueber Serialisierungsgrenzen, etwa wenn ein Fehler ueber JSON an den Client geschickt und dort wieder deserialisiert wird, waehrend eine Discriminated Union als reines Datenobjekt diese Grenze verlustfrei ueberquert.

In der Praxis kombiniert man beide Ansaetze oft: Auf der Serverseite existieren Error Klassen mit Verhalten, an der API Grenze werden sie in eine serialisierbare Discriminated Union uebersetzt, und auf der Clientseite arbeitet die gesamte UI Logik ausschliesslich mit dieser Union. So bleibt jede Seite bei dem Modell, das fuer ihre Anforderungen am besten passt.

8. Typische Fehler beim Modellieren von Error Unions

Ein haeufiger Fehler ist ein zu grobes Diskriminator Feld, das mehrere eigentlich unterschiedliche Fehlerarten unter einer einzigen Variante zusammenfasst. Wenn eine Variante { type: "error"; message: string } alle moeglichen Fehler abdeckt, ist das im Grunde wieder die generische Fehlerzeichenkette von Abschnitt eins, nur in einer Union verpackt. Der eigentliche Nutzen einer Discriminated Union entsteht erst, wenn tatsaechlich unterschiedliche, semantisch trennbare Fehlerarten als eigene Varianten existieren.


// WRONG: a single catch-all variant defeats the purpose of a union
type TooGenericError = { type: "error"; message: string };

// WRONG: missing default case silently allows incomplete handling
function badHandler(error: AppError): string {
  if (error.type === "network") {
    return "network problem";
  }
  return "unknown"; // validation, notFound, unauthorized all fall through here
}

// RIGHT: exhaustive switch with a never-typed fallback
function goodHandler(error: AppError): string {
  switch (error.type) {
    case "network":
      return `network problem: ${error.statusCode}`;
    case "validation":
      return `validation problem: ${error.field}`;
    case "notFound":
      return `not found: ${error.resourceId}`;
    case "unauthorized":
      return `unauthorized: needs ${error.requiredRole}`;
    default:
      return assertUnreachable(error);
  }
}

Ein zweiter verbreiteter Fehler ist die Verwendung von if/else if Ketten statt switch fuer die Behandlung einer Discriminated Union. Ohne den abschliessenden never Zweig im default Fall eines switch geht das Exhaustiveness Checking verloren, weil ein einfacher else Zweig ohne explizite Typpruefung nicht erzwingt, dass wirklich alle Varianten abgedeckt wurden. Ein dritter Fehler ist, den Diskriminator als string statt als Literal Union zu typisieren, wodurch TypeScript das Narrowing nicht mehr auf konkrete Varianten einschraenken kann.

9. Ansaetze im Vergleich

Die folgende Tabelle vergleicht drei gaengige Ansaetze zur Fehlermodellierung anhand praxisrelevanter Kriterien.

Kriterium Generischer Fehlercode Discriminated Union Error Klassen Hierarchie
Exhaustiveness Checking Nicht moeglich Ja, mit switch und never Nur mit zusaetzlichem Aufwand
Serialisierbarkeit Ja Ja, verlustfrei Nur mit manueller toJSON Methode
Verhalten an Fehler binden Nein Nur ueber separate Funktionen Ja, direkt als Methode
Feldspezifische Daten Nur als optionale Felder Ja, pro Variante praezise Ja, pro Klasse
Eignung fuer UI State Schlecht Sehr gut Moeglich, aber umstaendlich

Fuer UI Zustaende und API Grenzen ist eine Discriminated Union fast immer die passendere Wahl, weil sie serialisierbar bleibt und Exhaustiveness Checking direkt unterstuetzt. Fuer interne, serverseitige Logik mit echtem Verhalten pro Fehlerart bleiben Error Klassen eine sinnvolle Ergaenzung, insbesondere wenn eine bestehende Hierarchie bereits etabliert ist.

Mironsoft

TypeScript Typmodellierung, State Management und API Design

Fehlerzustaende, die der Compiler wirklich prueft?

Wir modellieren Fehler und UI Zustaende als exhaustive Discriminated Unions und richten Exhaustiveness Checking in eurer bestehenden TypeScript Codebase ein.

Typ Audit

Bestehende Fehlerbehandlung analysieren und Union Kandidaten identifizieren

State Machines

UI Zustaende als exhaustive Discriminated Unions modellieren

Team Schulung

Workshops zu Exhaustiveness Checking und Typmodellierung

10. Zusammenfassung

Discriminated Unions loesen ein grundlegendes Problem der Fehlerbehandlung in TypeScript: generische Fehlercodes oder lose Booleans verschweigen dem Compiler, welche Fehlerarten tatsaechlich existieren und welche Felder in welchem Fall verfuegbar sind. Eine Union aus klar benannten Varianten mit einem gemeinsamen Diskriminator Feld macht jede Fehlerart explizit und ermoeglicht typsicheres Narrowing ohne manuelle Assertions.

Der groesste praktische Gewinn ist Exhaustiveness Checking ueber switch und den never Type: Eine neue Fehlervariante, die irgendwo im Code noch nicht behandelt wird, erzeugt einen Compilerfehler, statt erst zur Laufzeit oder in Produktion aufzufallen. Kombiniert mit einem einheitlichen UI State, der Laden, Erfolg, Leerzustand und Fehler als eine einzige Union abbildet, entsteht eine Zustandsmaschine, die unsinnige Kombinationen wie gleichzeitiges Laden und Fehler strukturell ausschliesst.

Error Handling mit Discriminated Unions, das Wichtigste auf einen Blick

Grundprinzip

Ein gemeinsames Literal Feld unterscheidet mehrere Fehlervarianten, jede mit ihren eigenen, passenden Feldern.

Exhaustiveness

Ein never-typed default Zweig im switch erzwingt, dass jede Variante behandelt wird, sonst schlaegt die Kompilierung fehl.

UI States

Laden, Erfolg, Leerzustand und Fehler als eine Union statt mehrerer Booleans, keine unsinnigen Kombinationen mehr.

Abgrenzung

Fuer Daten und API Grenzen Discriminated Unions, fuer serverseitiges Verhalten ergaenzend Error Klassen einsetzen.

11. FAQ: Error Handling mit Discriminated Unions

1Was ist eine Discriminated Union?
Eine Vereinigung mehrerer Objekttypen mit gemeinsamem Literal Feld, ueber das TypeScript automatisch narrowt.
2Warum ist errorMessage problematisch?
Es verschweigt dem Compiler, welche Fehlerarten existieren und erfordert unsichere Zugriffe auf spezifische Daten.
3Was ist Exhaustiveness Checking?
Prueft, ob alle Unionsvarianten behandelt sind, ueber einen never-typed default Zweig im switch.
4Fehler mit Ladezustand kombinieren?
Eine einzige Union mit idle, loading, success und error statt getrennter Booleans.
5Union oder Klasse?
Union fuer serialisierbare Daten, Klasse wenn Verhalten direkt am Fehler haengen soll.
6Warum verschachtelte Error Unions?
Fuer mehrstufige Prozesse ordnet ein step Feld den Fehler dem richtigen Prozessschritt zu.
7Warum kein if/else?
if/else erzwingt keine Vollstaendigkeit, nur switch mit never-typed default tut das.
8Kombination mit Zod moeglich?
Ja, das Zod Fehlerobjekt wird in eine eigene, schlankere Union uebersetzt.
9string statt Literal Union?
Verhindert Narrowing, der Diskriminator muss ein konkretes Literal wie "network" sein.
10Funktioniert es bei vielen Varianten?
Ja, die Anzahl der Varianten spielt fuer den Mechanismus keine Rolle.