Discriminated Unions in TypeScript: Zustände typsicher modellieren

Mit Discriminated Unions machst du unmögliche Zustände unmöglich und zwingst TypeScript, wirklich jeden Fall zu behandeln. So funktioniert das Muster.

Teilen

Kennst du das Gefühl, wenn ein Objekt mal dieses und mal jenes Feld enthält und du nie ganz sicher bist, welche Kombination gerade gültig ist? Genau hier glänzen Discriminated Unions (auch tagged unions genannt). Sie sind eines der mächtigsten Muster in TypeScript, um Zustände so zu modellieren, dass ungültige Kombinationen gar nicht erst entstehen können. In diesem Beitrag schauen wir uns Schritt für Schritt an, wie das funktioniert und warum du danach kaum noch darauf verzichten möchtest.

Was ist eine Discriminated Union?

Eine Union verknüpft mehrere Typen mit dem |-Operator: Ein Wert ist entweder das eine oder das andere. Eine Discriminated Union ist ein Spezialfall davon, bei dem jeder Mitgliedstyp ein gemeinsames Feld mit einem eindeutigen, literalen Wert besitzt. Dieses Feld nennt man Discriminant (oder Tag).

type Kreis = {
  art: "kreis";
  radius: number;
};

type Rechteck = {
  art: "rechteck";
  breite: number;
  hoehe: number;
};

type Form = Kreis | Rechteck;

Das Feld art ist hier der Discriminant. Es ist kein beliebiger string, sondern ein String-Literal-Typ ("kreis" bzw. "rechteck"). Dieser feste Wert ist der Schlüssel zum ganzen Muster.

Narrowing über den Discriminant

Sobald du den Discriminant abfragst, weiß TypeScript automatisch, mit welchem konkreten Typ du gerade arbeitest. Dieses Eingrenzen nennt sich Narrowing und passiert ganz ohne zusätzliche Type Guards:

function berechneFlaeche(form: Form): number {
  switch (form.art) {
    case "kreis":
      // Hier ist form vom Typ Kreis -> form.radius existiert
      return Math.PI * form.radius ** 2;
    case "rechteck":
      // Hier ist form vom Typ Rechteck -> breite und hoehe existieren
      return form.breite * form.hoehe;
  }
}

Wenn du im case "kreis"-Zweig versehentlich form.breite schreibst, meckert der Compiler sofort. Der Discriminant wirkt wie eine Weiche, die TypeScript den richtigen Typ zuordnen lässt.

Ein praktisches Beispiel: Ladezustände

Das Muster spielt seine Stärke besonders bei Zustandsmodellen aus. Ein typischer Fall ist das Laden von Daten aus einer API. Ohne Union modelliert man das oft mit mehreren losen Feldern wie isLoading, data und error. Das Problem: Nichts verhindert, dass isLoading gleichzeitig mit einem error gesetzt ist. Mit einer Discriminated Union machst du solche unmöglichen Zustände buchstäblich unmöglich:

type Anfrage<T> =
  | { status: "leer" }
  | { status: "laedt" }
  | { status: "erfolg"; daten: T }
  | { status: "fehler"; nachricht: string };

function render(anfrage: Anfrage<string[]>): string {
  switch (anfrage.status) {
    case "leer":
      return "Noch keine Anfrage gestartet.";
    case "laedt":
      return "Lade Daten ...";
    case "erfolg":
      // daten ist nur hier verfügbar
      return `Geladen: ${anfrage.daten.length} Einträge`;
    case "fehler":
      // nachricht ist nur hier verfügbar
      return `Fehler: ${anfrage.nachricht}`;
  }
}

Auf daten kannst du nur im Zweig "erfolg" zugreifen, auf nachricht nur im Zweig "fehler". Der Compiler stellt sicher, dass du niemals auf ein Feld zugreifst, das im aktuellen Zustand gar nicht existiert.

Exhaustiveness-Check mit never

Ein besonders wertvoller Trick ist der Vollständigkeitscheck. Fügst du deiner Union später einen neuen Zustand hinzu und vergisst, ihn irgendwo zu behandeln, soll TypeScript dich warnen. Das erreichst du mit dem Typ never im default-Zweig:

function beschreibe(anfrage: Anfrage<unknown>): string {
  switch (anfrage.status) {
    case "leer":
      return "leer";
    case "laedt":
      return "laedt";
    case "erfolg":
      return "erfolg";
    case "fehler":
      return "fehler";
    default: {
      // Wird nur erreicht, wenn ein Fall fehlt
      const _check: never = anfrage;
      return _check;
    }
  }
}

Solange alle Fälle abgedeckt sind, ist anfrage im default-Zweig vom Typ never und die Zuweisung funktioniert. Ergänzt du der Union aber einen Status "abgebrochen", ohne ihn zu behandeln, ist anfrage plötzlich nicht mehr never und du bekommst einen Compile-Fehler. So kann dir kein vergessener Zustand durchrutschen.

Worauf du achten solltest

Damit das Muster sauber funktioniert, sind ein paar Punkte wichtig:

  • Verwende für den Discriminant immer einen Literal-Typ (String, Zahl oder Boolean), nicht den allgemeinen string-Typ.
  • Benenne das Discriminant-Feld in allen Mitgliedern gleich – hier status oder art.
  • Halte die Werte des Discriminants eindeutig: Kein Zustand darf denselben Tag wie ein anderer tragen.
  • Kombiniere das Muster mit switch und dem never-Check, um von der Vollständigkeitsprüfung zu profitieren.

Fazit

Discriminated Unions verwandeln vage Objektstrukturen in klar definierte Zustände. Durch das gemeinsame Discriminant-Feld grenzt TypeScript automatisch den richtigen Typ ein, und mit dem never-Trick erzwingst du, dass wirklich jeder Fall behandelt wird. Das Ergebnis ist Code, bei dem unmögliche Zustände nicht kompilieren – ein enormer Gewinn an Sicherheit, gerade bei Ladezuständen, Formularen oder Ereignistypen. Wenn du bisher mit einer Handvoll loser Booleans jongliert hast, probiere beim nächsten Mal eine Discriminated Union. Du wirst überrascht sein, wie viele Bugs dir der Compiler danach abnimmt.