Menu

TypeScript Discriminated Unions: Tagged Unions erklärt

Eine Discriminated Union ist eine Union aus Objekttypen, die eine gemeinsame Literal-Eigenschaft als Tag haben, etwa kind oder status. Die Prüfung des Tags engt das ganze Objekt ein. Das Muster, Narrowing mit switch, Vollständigkeitsprüfung mit never und wie du API-Ergebnisse, Request-Zustände und Zustandsautomaten modellierst.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Eine Discriminated Union (auch Tagged Union genannt) ist eine Union aus Objekttypen, die alle eine gemeinsame Eigenschaft haben, das Tag, mit einem anderen Literalwert in jedem Member. Die Prüfung des Tags sagt TypeScript, welchen Member du hast, und die übrigen Eigenschaften dieses Members werden verfügbar.

In case "circle" wäre das Lesen von shape.width ein Compilerfehler, weil der Kreis-Member kein width hat. Das Tag sind gewöhnliche Daten: Zur Laufzeit ist es einfach eine String-Eigenschaft, und der switch ist reines JavaScript.

Was eine Union zur Discriminated Union macht

Narrowing über das Tag funktioniert, wenn drei Bedingungen erfüllt sind:

  1. Jeder Member ist ein Objekttyp.
  2. Jeder Member hat denselben Eigenschaftsnamen (die Diskriminante). Der Name ist dir überlassen: kind, type, status und tag sind verbreitet.
  3. In jedem Member hat diese Eigenschaft einen Literaltyp: ein String, Zahl oder Boolean-Literal oder null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

Deklariert ein Member das Tag als einfaches string statt als Literal, engt der Vergleich des Tags die Union nicht mehr ein, und die Eigenschaften der einzelnen Member bleiben unerreichbar (TS2339).

Narrowing über das Tag

Jede Prüfung auf das Tag, die TypeScript versteht, engt das Objekt ein: switch, if/else, === und !==, sogar ein per Destructuring gelesenes Tag, solange es eine const ist:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Auch die Prüfung auf eine Eigenschaft mit "radius" in shape engt ein, aber der Vergleich eines Tags liest sich klarer und macht die Vollständigkeitsprüfung möglich.

Vollständigkeitsprüfungen

Der größte Vorteil des Musters zeigt sich, wenn die Union wächst. Mit ausdrücklichem Rückgabetyp und ohne default weiß TypeScript, dass der switch jedes Tag abdecken muss, ein neuer Member ohne case ist also ein Compilerfehler:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Diese Meldung sagt nicht, welcher Fall fehlt. Die Hilfsfunktion assertNever tut es, und sie wirft außerdem zur Laufzeit, wenn Daten von außen (JSON, ein älterer Client) ein Tag tragen, das laut Typen nicht existieren kann:

Füge einen fünften Status ohne case hinzu, und die Zeile assertNever(state) meldet Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never' und nennt dabei den Member. Mehr dazu auf der Seite zu never.

Unmögliche Zustände unmöglich machen

RequestState oben ersetzt eine verbreitete, schwächere Form:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

Mit optionalen Feldern besteht „lädt, mit Daten und einem Fehler“ die Typprüfung, und jeder Leser muss raten, welche Kombinationen wirklich vorkommen. Mit der Discriminated Union existiert data nur im Zustand success und error nur in error, also muss Code, der state.data liest, zuerst beweisen, dass er im Zustand success ist. Der Typ selbst dokumentiert die gültigen Zustände.

Ergebnisse modellieren: Erfolg oder Fehlschlag

Ein Boolean-Tag reicht für „hat funktioniert oder nicht“. Dieser Typ Result ist eine verbreitete Alternative zum Werfen von Exceptions bei erwarteten Fehlschlägen wie ungültigen Eingaben:

Der Aufrufer kann result.value nicht lesen, ohne vorher result.ok zu prüfen, und genau diese Prüfung vergisst man bei Exceptions oder Rückgaben von null leicht.

Zustandsautomaten und Reducer

Zwei Discriminated Unions, eine für Zustände und eine für Aktionen, beschreiben einen Zustandsautomaten. Ein Reducer macht einen switch über die Aktion und gibt den nächsten Zustand zurück; der Compiler prüft, dass jedes zurückgegebene Objekt ein gültiger Zustand ist:

{ ...state, name: "paused" } kompiliert nur, weil state vorher auf den Member für die Wiedergabe eingeengt wurde, sodass die Kopie track und position mitbringt. Nur { name: "paused" } zurückzugeben wäre ein Fehler: Ein pausierter Zustand braucht einen Track. Das ist dieselbe Form, die Reducer in Redux und useReducer in React verwenden.

Die Falle beim Widening

Das Tag muss ein Literaltyp bleiben. Bei einem Objekt, das ohne Annotation in einer Variablen aufgebaut wird, wird das Tag zu string erweitert, und dann passt es zu keinem Member:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

Die zugrunde liegende Regel, dass veränderbare Eigenschaften erweitert werden, wird auf der Seite zu Literaltypen erklärt.

Häufig gestellte Fragen

Was ist eine Discriminated Union in TypeScript?

Eine Union aus Objekttypen, in der jeder Member dieselbe Eigenschaft (die Diskriminante oder das Tag) mit einem anderen Literaltyp hat, zum Beispiel { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Die Prüfung shape.kind === "circle" engt shape auf den Kreis-Member ein, sodass seine übrigen Eigenschaften verfügbar werden.

Was ist der Unterschied zwischen einer Union und einer Discriminated Union?

Eine Discriminated Union ist eine Union mit einer zusätzlichen Regel: Alle Member teilen eine Eigenschaft mit einem eindeutigen Literaltyp. Eine einfache Union wie Cat | Fish muss mit in oder eigenen Type Guards eingeengt werden; eine Discriminated Union wird durch den Vergleich einer Eigenschaft eingeengt, und ein switch über diese Eigenschaft lässt sich auf Vollständigkeit prüfen.

Wie mache ich einen switch über eine Discriminated Union vollständig?

Gib der Funktion entweder einen ausdrücklichen Rückgabetyp und kein default (ein fehlender Fall ist dann der Fehler TS2366), oder ergänze default: return assertNever(value) mit function assertNever(x: never): never { throw ... }. Die zweite Form nennt den unbehandelten Member im Fehler und wirft außerdem zur Laufzeit, wenn unerwartete Daten ankommen.

Warum engt meine Discriminated Union nicht ein?

Das Tag muss in jedem Member ein Literaltyp sein. Wurde ein Objekt ohne Annotation erzeugt, wird sein kind zu string erweitert, und es passt zu keinem Member mehr (TS2345/TS2322). Behebe das mit einer Annotation, mit as const oder indem du es dort erzeugst, wo der Union-Typ erwartet wird. Ein Member mit kind: string verhindert ebenfalls, dass die Union über kind eingeengt wird.

Kann die Diskriminante ein Boolean oder eine Zahl sein?

Ja. Jeder Literaltyp funktioniert: Strings (kind: "circle"), Zahlen (version: 2), Booleans (ok: true / ok: false) und sogar null oder undefined. String-Tags sind am verbreitetsten, weil sie in Logs und als JSON lesbar sind.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S