Du hast einen Shape-Typ refactored, eine neue Variante hinzugefügt, und TypeScript blieb grün. Dein CI bestand. Dein Deploy lief durch. Dann traf ein User einen Runtime-Branch, der undefined zurückgab, und deine App hat im Production einen Fehler geworfen.

Der Schuldige war mit ziemlicher Sicherheit ein Switch-Statement. TypeScripts switch bietet keine Exhaustiveness-Garantie. Fügst du einer Union ein neues Member hinzu, wird jeder Switch über diese Union stillschweigend unvollständig. Der Compiler hält dich nicht auf. Er warnt dich nicht einmal. Das ist der Punkt, an dem viele stolpern: TypeScript kennt den vollständigen Satz an Möglichkeiten, aber Switch-Statements nehmen nicht an der Exhaustiveness-Prüfung teil.

Was Exhaustiveness-Checking tatsächlich bedeutet

Exhaustiveness bedeutet, dass der Compiler beweisen kann, dass jeder mögliche Wert behandelt wurde. Sprachen wie Rust und OCaml erzwingen das zur Compile-Zeit. TypeScript tut das nicht — zumindest nicht für Switch-Statements.

Betrachte eine discriminated union:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle is missing — and TypeScript is fine with it
  }
}

Das kompiliert. Es gibt aber auch undefined zurück, wenn shape.kind === "triangle". Die Funktionssignatur verspricht ein number, aber die Implementierung liefert keinen. TypeScript markiert den fehlenden Fall nicht, weil Switch-Blöcke nicht auf Vollständigkeit geprüft werden.

Das Object-Lookup-Pattern

Der einfachste Ersatz ist eine schlichte Object-Map. Jeder Key mapped auf eine Handler-Funktion. Du indexierst in das Object mit dem Discriminant, und der Compiler prüft, ob der Key existiert.

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

Das ist besser. Wenn du "polygon" zu Shape hinzufügst, beschwert sich TypeScript, dass areaHandlers einen Key vermisst. Der Fehler zeigt an die richtige Stelle, und er tritt zur Compile-Zeit auf.

Aber die Casts sind hässlich. Wir können es besser machen.

Typsichere Handler ohne Casting

Der Trick besteht darin, jede Variante auf eine Funktion zu mappen, die den genarrowten Typ direkt erhält. Du verwendest einen mapped type, um die Beziehung zwischen dem Key und der spezifischen Shape-Variante zu erhalten.

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

Jetzt erhält jeder Handler ein ordentlich genarrowtes Argument. Keine manuellen Casts innerhalb der Funktionen. Das as never in der letzten Zeile ist ein kleiner Makel, aber er ist lokalisiert und sicher. Der wahre Wert liegt darin, dass areaHandlers jeden Key in ShapeKind enthalten muss. Entfernst du einen, oder fügst du Shape eine neue Variante hinzu, bricht der Build.

Exhaustiveness-Checking mit einem Never-Assert

Einige Teams bevorzugen es, den Switch zu behalten, aber einen Compile-Time-Exhaustiveness-Guard hinzuzufügen. Das ist ein pragmatischer Kompromiss. Du fügst einen Default-Branch hinzu, der never annimmt und den Compiler zwingt, zu verifizieren, dass alle Fälle behandelt wurden.

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

Wenn du eine neue Variante hinzufügst und vergisst, einen Case hinzuzufügen, erhält der default-Branch einen Nicht-never-Wert. TypeScript wirft einen Fehler, weil du keinen konkreten Typ never zuweisen kannst. Der Fehler zeigt auf den Switch — genau dort, wo du ihn haben willst.

Dieses Pattern wird bei Sentry und anderen großen TypeScript-Codebases häufig verwendet. Es erfordert keine Änderung der Struktur deines Codes. Es sorgt einfach dafür, dass der Compiler für dich statt gegen dich arbeitet.

Pattern Matching mit Third-Party-Libraries

Wenn du etwas näher an Rusts match möchtest, bieten Libraries wie ts-pattern Pattern Matching mit eingebautem Exhaustiveness-Checking.

import { match, P } from "ts-pattern";

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

Der .exhaustive()-Aufruf ist der Schlüssel. Wenn eine Variante fehlt, meldet TypeScript einen Fehler. Die Library unterstützt außerdem verschachtelte Patterns, Guards und Wildcards, die komplexe verschachtelte Switch-Blöcke ersetzen können.

Das ist mächtig, aber es fügt eine Dependency hinzu. Für eine einzelne discriminated union ist ts-pattern Overkill. Für Codebases mit intensivem Pattern Matching lohnt sich die Bundle-Size.

Wo Switch-Statements immer noch gewinnen

Switch ist nicht immer falsch. Wenn deine Cases durchfallen müssen, oder wenn die Logik imperativ ist mit Early Returns und Side Effects, kann ein Switch lesbarer sein als eine Lookup-Table oder eine Kette von .with()-Aufrufen.

Das Problem ist nicht die Syntax. Es ist das Fehlen von Exhaustiveness-Checking. Wenn du Switch verwendest, füge den assertNever-Guard hinzu. Das sind zwei Zeilen Code, und sie schließen die Sicherheitslücke.

So adoptierst du das, ohne alles neu zu schreiben

Du musst heute nicht alle deine Switches refactoren. Hier ist eine pragmatische Reihenfolge:

  1. Füge assertNever deinen Shared Utilities hinzu. Verwende ihn in neuen Switch-Blöcken.
  2. Wenn du bestehende Switches über Unions anfasst, füge dann den Guard hinzu.
  3. Für neuen Code über discriminated unions bevorzuge das Object-Map-Pattern, wenn die Logik rein ist und jeder Case unabhängig ist.
  4. Evaluiere ts-pattern, wenn du dich mehr als ein paar Mal in verschachtelten Switches oder komplexer Matching-Logik wiederfindest.

Das Ziel ist nicht, Switch-Statements zu eliminieren. Es ist, den Compiler den Bug fangen zu lassen, bevor deine User es tun.

Wenn du das in Aktion sehen willst, versuche, einen Handler aus dem AreaMap-Beispiel zu löschen, und sieh zu, wie tsc sich beschwert. Diese rote Wellenlinie ist der Unterschied zwischen einem ruhigen Freitag-Deploy und einem Weekend-PagerDuty-Alarm.