Du hast einen State hinzugefügt. Du hast einen Handler vergessen. Niemand hat dich gewarnt.

State Machines starten sauber. Drei Werte, drei switch-Branches. Dann fügt Retry-Logic einen vierten State hinzu. Partial Failure einen fünften. Du aktualisierst den Reducer, vergisst aber die Status-Badge-Component, den Analytics-Mapper und den Export-Formatter.

Alles kompiliert. Der Bug taucht zwei Sprints später auf, wenn ein User in einem Corner Case, den du nie manuell getestet hast, auf einen leeren Screen stößt.

TypeScript kann das unmöglich machen. Nicht mit einer Linter-Rule. Nicht mit einem Test. Mit dem Type System selbst.

Was Exhaustiveness Checking wirklich bedeutet

Exhaustiveness Checking bedeutet, dass der Compiler beweist, dass du jede Variante eines Typs behandelt hast. Rust und OCaml machen das standardmäßig. TypeScript kann das auch, aber du musst danach fragen.

Der Trick kombiniert zwei Dinge: Discriminated Unions und einen Helper, der nur den Typ never akzeptiert.

Wie Discriminated Unions States modellieren

Eine Discriminated Union ist ein TypeScript-Typ, bei dem jede Variante eine gemeinsame Literal-Property hat, die Discriminant genannt wird. Bei State Machines ist das fast immer ein status-, kind- oder type-Feld.

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }

Jede Variante trägt genau die Daten, die für diesen State relevant sind. Es gibt kein optionales data-Feld bei idle, kein message-Feld bei success. Die Form des Objekts sagt dir, in welchem State du bist, und der Compiler narrowed den Typ automatisch innerhalb eines switch.

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
  }
}

Innerhalb von case "loading" ist der Typ { status: "loading"; requestId: string }, also ist state.requestId verfügbar. Der Compiler narrowed automatisch, weil das status-Literal für jede Variante unterschiedlich ist.

Das schlägt String Enums und Boolean-Flag-Soup. Aber es ist noch nicht exhaustive. Entferne den error-Fall und TypeScript kompiliert trotzdem. Die Funktion gibt implizit undefined zurück, und der Compiler schweigt.

Der never-Trick, der exhaustive Behandlung erzwingt

Hier ist der Helper:

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}

Diese Funktion akzeptiert nur never, die leere Union, den Typ ohne Werte. Es ist unmöglich, assertNever mit einem echten Wert aufzurufen, es sei denn, du hast dem Compiler etwas vorgemacht.

Füge ihn nun ans Ende deines switch ein:

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
    default:
      return assertNever(state)
  }
}

Wenn jede mögliche Variante von State oberhalb des default behandelt wird, dann wurde state innerhalb des default-Branches zu never genarrowed. Der Aufruf von assertNever(state) kompiliert.

Wenn du einen neuen State hinzufügst und einen Fall vergisst, erhält der default-Branch nun einen echten Typ. Du kannst keinen echten Typ an einen never-Parameter übergeben. Der Compiler wirft einen Type Error.

Wie es in der Praxis scheitert

Füge einen retrying-State hinzu:

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number; lastError: string }

Nun kompiliert handleState nicht mehr:

Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.

Der Fehler zeigt direkt auf den default-Branch. Kein Runtime Crash. Eine Compile-Time-Verweigerung, bis du entscheidest, was retrying in dieser spezifischen Funktion bedeutet.

Jede Funktion, die auf State switcht, bekommt ihren eigenen Fehler. Der Compiler lässt dich die Arbeit nicht aufschieben.

Das Pattern skaliert auf jeden Union-Typ

Das ist nicht spezifisch für State Machines. Jede Discriminated Union profitiert von derselben Behandlung.

Event Handler:

type Event =
  | { type: "USER_LOGIN"; userId: string }
  | { type: "USER_LOGOUT" }
  | { type: "PAGE_VIEW"; path: string }

function handleEvent(event: Event): void {
  switch (event.type) {
    case "USER_LOGIN":
      trackLogin(event.userId)
      return
    case "USER_LOGOUT":
      trackLogout()
      return
    case "PAGE_VIEW":
      trackPageView(event.path)
      return
    default:
      assertNever(event)
  }
}

Redux-style Reducer:

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.amount
    case "decrement":
      return state - action.amount
    case "reset":
      return 0
    default:
      return assertNever(action)
  }
}

Union von Objekten mit einem Literal-Discriminant. Switche darauf. Defaulte zu assertNever. Füge eine Variante hinzu, und jeder switch explodiert, bis du ihn behandelst.

Wo das an seine Grenzen stößt und was du dagegen tun kannst

Exhaustiveness Checking ist keine Magie. Kenne die scharfen Kanten, bevor du es überall einführst.

Du musst eine Discriminated Union verwenden

Das funktioniert nicht mit einfachen String Enums oder Boolean Flags. Der Compiler braucht eine gemeinsame Literal-Property, um darauf zu narrown.

// This does NOT work for exhaustiveness checking
enum Status {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
}

assertNever beweist Exhaustiveness nur bei closed unions. Bei Enums nimmt der Compiler an, dass jeder passende String gültig ist. Er kann nicht beweisen, dass du einen Fall verpasst hast.

Du darfst any oder Type Assertions nicht verwenden

Wenn du durch as State castest oder aus einer any-typisierten Response liest, überspringt der Compiler das Narrowing. Validiere an den Grenzen, dann vertraue den Typen im Inneren.

assertNever wirft zur Runtime als letztes Mittel

Die Funktion ist zuerst ein Compile-Time Guard, zweitens ein Runtime Guard. In der Theorie triffst du den throw nie. In der Praxis machen ein Cast oder veraltetes JSON sie zu deinem Safety Net. Das ist besser, als stillschweigend undefined zurückzugeben.

Es wird laut bei vielen Branches

Das Hinzufügen eines elften Falls löst Fehler in jeder Funktion aus, die auf den Typ switcht. Das ist der Sinn. Aber wenn deine Union über sechs oder sieben Varianten hinauswächst, erwäge, sie in kleinere Sub-Unions oder separate Machines aufzuteilen.

So führst du das ein, ohne alles neu zu schreiben

Beginne mit der State Machine, die die meisten Bugs verursacht.

  1. Finde ein status-Feld, das als string oder als wide enum typisiert ist. Ersetze es durch eine closed union.
  2. Füge assertNever zu Funktionen hinzu, die darauf switchen.
  3. Lass dich vom Compiler führen.
  4. Füge eine Code-Review-Checkliste hinzu, die assertNever in neuen Reducern fordert.

Auch mit XState wirst du weiterhin Mapper und Selector schreiben, die auf State Values switchen. Diese Funktionen brauchen immer noch Exhaustiveness Checking.

FAQ

Funktioniert das mit if/else statt switch?

Ja, aber es ist fragil. Du brauchst ein finales else, das assertNever aufruft. Wenn du die Bedingungen neu anordnest oder einen Early Return einfügst, kannst du die Prüfung versehentlich verlieren. switch ist sicherer, weil der default-Branch offensichtlich ist.

Was, wenn mir einige States in einer bestimmten Funktion wirklich egal sind?

Behandle sie explizit. Lass sie nicht versehentlich bis zu assertNever durchfallen.

case "retrying":
  // No-op in this context
  return ""

Der Compiler zwingt dich, die Entscheidung sichtbar zu machen. Das ist der Sinn.

Kann ich einen Wert aus assertNever zurückgeben?

Eine Funktion, die never zurückgibt, ist jedem Return Type zuweisbar, weil sie nie wirklich zurückkehrt. Wenn du einen Fallback statt eines Throws willst:

function assertNever(value: never, fallback: string): string {
  console.error("Unhandled state:", value)
  return fallback
}

Der value: never-Parameter hält den Compile-Time-Check intakt.

Funktioniert das in JavaScript?

Nein. Das ist ein Compile-Time-TypeScript-Feature. Zur Runtime ist assertNever nur eine Funktion, die wirft. Die Sicherheit kommt davon, dass der Type Checker sich weigert, Code zu bauen, der sie erreichen könnte.

Mach den Compiler zu deinem zweiten Reviewer

Jedes Mal, wenn du einen State hinzufügst, schaffst du Arbeit im Reducer, im UI, in der Analytics Pipeline, in der Export Logic. Das Standard-Ergebnis ist, dass du einen dieser Orte verpasst, shippst und es später merkst.

Exhaustiveness Checking dreht das Default-Ergebnis um. Der Compiler wird zu einem Reviewer, der nie vergisst, die switch-Statements zu prüfen. Er ersetzt keine Tests, aber er fängt eine Kategorie von Bugs ab, die Tests selten abdecken: den, bei dem du einfach nicht wusstest, dass du Code schreiben musstest.

Füge den Helper hinzu. Benutze ihn in einem Reducer. Das nächste Mal, wenn du eine State Machine erweiterst, lass TypeScript dir sagen, wo die Arbeit ist. Er wird keinen Branch verpassen.