Zum Inhalt springen
aviral gupta

// B3.4 · ca. 30 Min. · Einstieg

Discriminated Unions

Nach dieser Lektion machen Sie aus einem Typ voller optionaler Properties eine Discriminated Union, grenzen sie mit if, switch, gestapelten cases und result.ok ein und destrukturieren sie, ohne das Eingrenzen zu verlieren.

Lektion 4 von 5 in B3 Eingrenzung (Narrowing)

Danach können Sie

  • Erklären, warum ein Typ mit optionalen Properties nicht eingegrenzt werden kann, und jeden Fall als eigenes Mitglied modellieren
  • Eine Discriminated Union mit ===, switch, gestapelten cases und booleschen Diskriminanten wie result.ok eingrenzen
  • Das Eingrenzen behalten, wenn Sie die Diskriminante destrukturieren oder eine Prüfung in einer Konstanten speichern
  1. Aufwärmen · Aufgabe 1 von 7

    Zum Aufwärmen aus JavaScript: Manche cases unten haben kein break. Was gibt das aus?

    const kind: string = "image";
    const out: string[] = [];
    switch (kind) {
      case "text":
        out.push("text");
      case "image":
      case "attachment":
        out.push("media");
      case "link":
        out.push("link");
        break;
      default:
        out.push("other");
    }
    console.log(out.join(" "));
  2. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie es vorher, bevor Sie weiterlesen. Das ist der erste Versuch des Handbooks für einen Typ Shape. Was sagt tsc zu getArea?

    interface Shape {
      kind: "circle" | "square";
      radius?: number;
      sideLength?: number;
    }
    
    function getArea(shape: Shape) {
      if (shape.kind === "circle") {
        return Math.PI * shape.radius ** 2;
      }
      return 0;
    }
  3. Üben · Aufgabe 3 von 7

    Setzen Sie den Typ von ok im zweiten Mitglied ein, damit if (result.ok) eingrenzt und das Programm sent #7 und failed: offline ausgibt.

    type Sent = { ok: true; id: number } | { ok: ____; error: string };
    
    function report(result: Sent): string {
      if (result.ok) {
        return "sent #" + result.id;
      }
      return "failed: " + result.error;
    }
    
    console.log(report({ ok: true, id: 7 }));
    console.log(report({ ok: false, error: "offline" }));
    | { ok: ; error: string };
  4. Üben · Aufgabe 4 von 7

    Ordnen Sie mit den Typen unten jeder Situation den Typ zu, den m oder r an dieser Stelle hat.

    type Text = { type: "text"; body: string };
    type Image = { type: "image"; url: string; width: number };
    type Attachment = { type: "attachment"; name: string; bytes: number };
    type Message = Text | Image | Attachment;
    type Ok = { ok: true; id: number };
    type Failed = { ok: false; error: string };
    type Sent = Ok | Failed;
    
  5. Üben · Aufgabe 5 von 7

    Ein case hat kein break. Welche Zeile lehnt tsc ab?

    type Text = { type: "text"; body: string };
    type Image = { type: "image"; url: string; width: number };
    type Attachment = { type: "attachment"; name: string; bytes: number };
    type Message = Text | Image | Attachment;
    
    function label(m: Message): string {
      let text = "";
      switch (m.type) {
        case "text":
          text = m.body;
        case "image":
          text += m.url;
          break;
        case "attachment":
          text = m.name;
      }
      return text;
    }
  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. error ist ein Error oder null, und data ist null oder ein String-Array. Keins davon ist ein String-Literal. Was meldet tsc für names?

    type Fetched = { error: Error; data: null } | { error: null; data: string[] };
    
    function names(r: Fetched): number {
      if (r.error) throw r.error;
      return r.data.length;
    }
  7. Anwenden · Aufgabe 7 von 7

    Kleine Aufgabe. Ein Smart Home meldet drei Arten von Ereignissen: Bewegung in einem Raum, eine Temperatur in einem Raum und das Öffnen oder Schließen der Haustür. Modellieren Sie sie als Discriminated Union HomeEvent und schreiben Sie describe(e), das die Daten eines Mitglieds erst nach einer Prüfung von kind liest. Stapeln Sie die cases der beiden Ereignisse, die einen Raum haben. Schreiben Sie dann readCelsius(room, raw), das { ok: true; event } liefert oder { ok: false; error } für Werte außerhalb von -40 bis 85, und geben Sie jedes Ergebnis nach einer Prüfung von ok aus. Prüfen Sie es im Editor oder mit npx tsc und node main.ts.

    Prüfen Sie Ihr Ergebnis anhand dieser Liste

Selbst programmieren

Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.

Ausgearbeitetes Beispiel

Ein Chat-Postausgang

Drei Arten von Nachrichten teilen sich eine Property type mit einem anderen Literal in jedem Mitglied. preview stapelt die beiden Medien-cases, kopiert die Diskriminante in eine const, um sie zu unterscheiden, und überlässt Text dem default. send meldet das Ergebnis mit der booleschen Diskriminante ok. Führen Sie npx tsc aus, dann node main.ts. Ändern Sie danach const { type } in let { type } und führen Sie tsc erneut aus.

main.ts

// A chat client: one object type per kind of message, told apart by "type".
type Text = { type: "text"; body: string };
type Image = { type: "image"; url: string; width: number };
type Attachment = { type: "attachment"; name: string; bytes: number };
type Message = Text | Image | Attachment;

// A boolean discriminant: ok says which of the two you have.
type Sent = { ok: true; id: number } | { ok: false; error: string };

function preview(m: Message): string {
  switch (m.type) {
    case "image":
    case "attachment": {
      // m is Image | Attachment here
      const { type } = m; // a const copy of the discriminant still narrows m
      if (type === "image") return "[image, " + m.width + " px]";
      return "[file " + m.name + ", " + Math.ceil(m.bytes / 1024) + " KB]";
    }
    default:
      // only Text is left
      return '"' + (m.body.length > 20 ? m.body.slice(0, 20) + "..." : m.body) + '"';
  }
}

let nextId = 1;

function send(m: Message): Sent {
  if (m.type === "text" && m.body.trim() === "") return { ok: false, error: "empty text" };
  if (m.type === "attachment" && m.bytes > 5_000_000) return { ok: false, error: "file over 5 MB" };
  return { ok: true, id: nextId++ };
}

const outbox: Message[] = [
  { type: "text", body: "Are we still on for Friday at noon?" },
  { type: "image", url: "/pics/cat.png", width: 640 },
  { type: "text", body: "   " },
  { type: "attachment", name: "notes.pdf", bytes: 48_213 },
  { type: "attachment", name: "film.mp4", bytes: 912_000_000 },
];

for (const m of outbox) {
  const result = send(m);
  if (result.ok) {
    console.log("#" + result.id + " " + preview(m));
  } else {
    console.log("not sent (" + result.error + "): " + preview(m));
  }
}

Ausführen mit

npx tsc
node main.ts

Ausgabe

#1 "Are we still on for ..."
#2 [image, 640 px]
not sent (empty text): "   "
#3 [file notes.pdf, 48 KB]
not sent (file over 5 MB): [file film.mp4, 890625 KB]
  • Mit let { type } = m meldet tsc m.width und m.name: Ein let könnte sich vor dem if ändern, also grenzt es m nicht mehr ein.
  • In default bleibt nur Text übrig, also braucht m.body keine Prüfung.
  • result.id gibt es erst nach if (result.ok); im else-Zweig ist result.error das, was Sie lesen.
  • Keine Property ist optional, und nirgends steht ein !: Jedes Mitglied sagt genau, was es hat.
Ändern und ausführen

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den TypeScript-Compiler herunter (bis zu 2.1 MB) und speichert ihn im Cache. Ihr Code bleibt auf Ihrem Gerät.

Übungen

Übung 1 von 2

Den Liefertyp aufteilen

Delivery ist ein einziger Typ mit optionalen Properties, also meldet tsc error TS18048: 'd.store' is possibly 'undefined', obwohl label zuerst method prüft. Lassen Sie label unverändert. Ersetzen Sie Delivery durch eine Discriminated Union aus zwei exportierten Typen, Pickup (ein Laden) und Courier (eine Adresse und ein Stockwerk), jeder mit Pflichtdaten.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den TypeScript-Compiler herunter (bis zu 2.1 MB) und speichert ihn im Cache. Ihr Code bleibt auf Ihrem Gerät.

Hinweise
  1. Hinweis 1

    Die Prüfung in label ist in Ordnung. Der Typ muss sagen, dass eine Abholung immer einen Laden hat und eine Kurierlieferung immer eine Adresse und ein Stockwerk.

  2. Hinweis 2

    Schreiben Sie zwei Objekttypen, jeden mit method auf ein Literal gesetzt, "pickup" oder "courier", und ohne Fragezeichen.

  3. Hinweis 3

    export type Pickup = { method: "pickup"; store: string }; dann Courier genauso, und export type Delivery = Pickup | Courier;

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

export type Pickup = { method: "pickup"; store: string };
export type Courier = { method: "courier"; address: string; floor: number };
export type Delivery = Pickup | Courier;

export function label(d: Delivery): string {
  if (d.method === "pickup") {
    return "Pick up at " + d.store.toUpperCase();
  }
  return "Courier to " + d.address + ", floor " + d.floor.toFixed(0);
}
Auf dem eigenen Computer ausführen

Installieren Sie TypeScript 7.0 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.ts

export type Delivery = {
  method: "pickup" | "courier";
  store?: string;
  address?: string;
  floor?: number;
};

export function label(d: Delivery): string {
  if (d.method === "pickup") {
    return "Pick up at " + d.store.toUpperCase();
  }
  return "Courier to " + d.address + ", floor " + d.floor.toFixed(0);
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {label} from './main.ts';

test('eine Abholung nennt den Laden in Großbuchstaben', () => {
  const text = label({method: 'pickup', store: 'Mitte'});
  assert.equal(text, 'Pick up at MITTE', `label einer Abholung ergab ${JSON.stringify(text)}`);
});

test('eine Kurierlieferung nennt Adresse und Stockwerk', () => {
  const text = label({method: 'courier', address: 'Hauptstr. 5', floor: 3});
  assert.equal(text, 'Courier to Hauptstr. 5, floor 3', `label einer Kurierlieferung ergab ${JSON.stringify(text)}`);
});

test('das Erdgeschoss ist Stockwerk 0', () => {
  const text = label({method: 'courier', address: 'Ring 1', floor: 0});
  assert.equal(text, 'Courier to Ring 1, floor 0', `label einer Lieferung ins Erdgeschoss ergab ${JSON.stringify(text)}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc braucht TypeScript im Ordner: Führen Sie dort einmal npm install --save-dev typescript aus. tsc prüft die Typen; Node.js führt main.ts aus, indem es sie entfernt.

Programm ausführen:

npx tsc
node main.ts

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

npx tsc
node --test
learnrun.js herunterladen

Übung 2 von 2

Erst ok prüfen, dann den Port lesen

parsePort liefert ein PortResult. describePort liest sofort result.port, und tsc meldet error TS2339: Property 'port' does not exist on type 'PortResult'. Sorgen Sie dafür, dass describePort für "8080" "port 8080" liefert und, wenn das Parsen scheitert, "invalid: " gefolgt vom Grund.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den TypeScript-Compiler herunter (bis zu 2.1 MB) und speichert ihn im Cache. Ihr Code bleibt auf Ihrem Gerät.

Hinweise
  1. Hinweis 1

    PortResult hat zwei Mitglieder, und nur das mit ok: true hat einen port. Was müssen Sie zuerst prüfen?

  2. Hinweis 2

    if (!result.ok) grenzt result auf das Mitglied mit reason ein. Nach einem return in diesem Zweig bleibt nur das Mitglied mit port übrig.

  3. Hinweis 3

    if (!result.ok) { return "invalid: " + result.reason; } dann return "port " + result.port;

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

export type PortResult = { ok: true; port: number } | { ok: false; reason: string };

export function parsePort(text: string): PortResult {
  const port = Number(text);
  if (!Number.isInteger(port)) return { ok: false, reason: "not a whole number" };
  if (port < 1 || port > 65535) return { ok: false, reason: "out of range" };
  return { ok: true, port };
}

export function describePort(text: string): string {
  const result = parsePort(text);
  if (!result.ok) {
    return "invalid: " + result.reason;
  }
  return "port " + result.port;
}
Auf dem eigenen Computer ausführen

Installieren Sie TypeScript 7.0 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.ts

export type PortResult = { ok: true; port: number } | { ok: false; reason: string };

export function parsePort(text: string): PortResult {
  const port = Number(text);
  if (!Number.isInteger(port)) return { ok: false, reason: "not a whole number" };
  if (port < 1 || port > 65535) return { ok: false, reason: "out of range" };
  return { ok: true, port };
}

export function describePort(text: string): string {
  const result = parsePort(text);
  return "port " + result.port;
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {describePort} from './main.ts';

test('ein gültiger Port wird beschrieben', () => {
  assert.equal(describePort('8080'), 'port 8080', 'describePort("8080") sollte "port 8080" sein');
});

test('Text, der keine Zahl ist, liefert seinen Grund', () => {
  assert.equal(describePort('abc'), 'invalid: not a whole number', 'describePort("abc") sollte "invalid: not a whole number" sein');
});

test('eine Zahl außerhalb von 1 bis 65535 liefert ihren Grund', () => {
  assert.equal(describePort('70000'), 'invalid: out of range', 'describePort("70000") sollte "invalid: out of range" sein');
  assert.equal(describePort('0'), 'invalid: out of range', 'describePort("0") sollte "invalid: out of range" sein');
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc braucht TypeScript im Ordner: Führen Sie dort einmal npm install --save-dev typescript aus. tsc prüft die Typen; Node.js führt main.ts aus, indem es sie entfernt.

Programm ausführen:

npx tsc
node main.ts

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

npx tsc
node --test
learnrun.js herunterladen

Häufige Fehler

Ein falsch geschriebenes case-Label

type Text = { type: "text"; body: string };
type Image = { type: "image"; url: string; width: number };
type Attachment = { type: "attachment"; name: string; bytes: number };
type Message = Text | Image | Attachment;

function icon(m: Message): string {
  switch (m.type) {
    case "text":
      return "T";
    case "imgae":
      return "I";
    default:
      return "F";
  }
}

console.log(icon({ type: "image", url: "/a.png", width: 10 }));

Was tsc oder Node.js ausgibt

main.ts(10,10): error TS2678: Type '"imgae"' is not comparable to type '"attachment" | "image" | "text"'.

Warum, und die Lösung

Ein case-Wert muss einer der Literal-Typen der Diskriminante sein, und tsc zählt die erlaubten auf. Schreiben Sie case "image". Ohne tsc würde das Programm laufen und für jedes Bild still F ausgeben, weil der falsch geschriebene case nie passt.

Die Diskriminante mit let destrukturieren

type Text = { type: "text"; body: string };
type Image = { type: "image"; url: string; width: number };
type Attachment = { type: "attachment"; name: string; bytes: number };
type Message = Text | Image | Attachment;

function link(m: Message): string {
  let { type } = m;
  if (type === "image") {
    return m.url;
  }
  return "";
}

console.log(link({ type: "image", url: "/a.png", width: 10 }));

Was tsc oder Node.js ausgibt

main.ts(9,14): error TS2339: Property 'url' does not exist on type 'Message'.

Warum, und die Lösung

tsc hält die Verbindung zwischen type und m nur bei einer const (oder einem Parameter, der nie neu zugewiesen wird): Ein let könnte zum Zeitpunkt der Prüfung einen anderen Wert haben. Schreiben Sie const { type } = m; oder prüfen Sie direkt m.type.

Daten destrukturieren, die nur ein Mitglied hat

type Sent = { ok: true; id: number } | { ok: false; error: string };

function report({ ok, id }: Sent): string {
  return ok ? "sent #" + id : "failed";
}

console.log(report({ ok: true, id: 7 }));

Was tsc oder Node.js ausgibt

main.ts(3,23): error TS2339: Property 'id' does not exist on type 'Sent'.

Warum, und die Lösung

Ein Destrukturierungsmuster läuft vor jeder Prüfung, also darf es nur Properties nehmen, die jedes Mitglied hat. ok steht in beiden Mitgliedern, id nicht. Nehmen Sie das ganze Objekt, prüfen Sie ok und lesen Sie dann result.id: function report(result: Sent) { return result.ok ? "sent #" + result.id : "failed"; }.

TypeScript im Browser: der Compiler von TypeScript 6.0.3, Apache-2.0, danach die eigene Engine Ihres Browsers. Lizenz und Quellcode

Abschlussquiz

5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.

Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Warum optionale Properties nicht genügen

Die erste Shape im Handbook ist ein einziges Interface: kind: "circle" | "square", radius?: number und sideLength?: number. In if (shape.kind === "circle") ist shape.radius weiter number | undefined, und shape.radius ** 2 ist error TS18048: 'shape.radius' is possibly 'undefined'. Der Typ verknüpft kind nicht mit radius, also beweist die Prüfung nichts. shape.radius! bringt tsc zum Schweigen, aber nichts prüft es: Mit { kind: "circle" } aufgerufen, ergibt es NaN. Schreiben Sie stattdessen einen Objekttyp pro Fall, jeden mit einem Literalwert in einer gemeinsamen Property und mit seinen eigenen Pflichtdaten. Das ist eine Discriminated Union, und die Property, hier kind, ist ihre Diskriminante. Jeder Name geht: type, status, ok.

Eingrenzen über die Diskriminante

Ohne Prüfung dürfen Sie nur Properties lesen, die jedes Mitglied hat, etwa m.type. === und !== auf der Diskriminante grenzen ein, ebenso ein frühes return und switch. Gestapelte cases, case "image": case "attachment":, ergeben m: Image | Attachment, und default bekommt den Rest. Ein case ohne return oder break fällt durch, also sieht auch der nächste case das frühere Mitglied, und tsc lehnt ab, was diesem fehlt. Ein Wert, der nicht in der Union liegt, ist im Vergleich error TS2367 und im case TS2678. Literale Booleans funktionieren auch: Bei { ok: true; id: number } | { ok: false; error: string } liefert if (result.ok) das erste Mitglied, und if (!result.ok) return lässt es übrig.

Destrukturieren und Prüfungen in Konstanten

Seit TypeScript 4.4 grenzt const { type } = m; if (type === "image") weiterhin m ein, ebenso const isImage = m.type === "image"; if (isImage). Seit 4.6 grenzt const { kind, amount } = action auch amount ein, wenn Sie kind prüfen. Das gilt für eine const, eine readonly-Property oder einen Parameter, den die Funktion nie neu zuweist. Mit let löst tsc die Verbindung, weil sich die Variable später ändern könnte: let { type } = m; if (type === "image") m.url ist error TS2339: Property 'url' does not exist on type 'Message'. Und destrukturieren können Sie nur, was jedes Mitglied hat: ({ ok, id }: Sent) ist ebenfalls error TS2339, weil das Fehlschlag-Mitglied kein id hat.

Quellen

Zuletzt geprüft am 4. Oktober 2026