Zum Inhalt springen
aviral gupta

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

Union-Typen und Narrowing

Nach dieser Lektion sehen Sie einer Funktion mit Union-Typ an, welche Zeile der Compiler ablehnt und warum, und Sie schreiben ein switch, das nicht mehr kompiliert, sobald ein neuer Fall hinzukommt.

Lektion 1 von 5 in B3 Eingrenzung (Narrowing)

Anfang des Moduls

Danach können Sie

  • Eine Union mit typeof, in, instanceof und Gleichheitsprüfungen eingrenzen
  • Eine Discriminated Union mit kind-Feld und ein vollständiges switch mit never schreiben
  • Erklären, warum unknown eine Prüfung erzwingt, während any die Prüfung abschaltet
  1. Aufwärmen · Aufgabe 1 von 7

    Zum Aufwärmen: Ein Parameter ist als id: number | string deklariert. Was sagt dieser Typ?

    function printId(id: number | string) {
      console.log("Your ID is: " + id);
    }
  2. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie es voraus, bevor wir es erklären. Was passiert mit dieser Funktion, wenn sie mit strict kompiliert wird?

    function size(value: string[] | null) {
      if (typeof value === "object") {
        return value.length;
      }
      return 0;
    }
  3. Üben · Aufgabe 3 von 7

    Mit diesen Typen: Ordnen Sie jeder Prüfung zu, was TypeScript im if-Zweig weiß.

    type Fish = { swim: () => void };
    type Bird = { fly: () => void };
    interface Circle { kind: "circle"; radius: number }
    interface Square { kind: "square"; sideLength: number }
    type Shape = Circle | Square;
  4. Üben · Aufgabe 4 von 7

    Welche Zeile lehnt der Compiler ab (strict)?

    function handle(a: any, b: unknown) {
      a.trim();                            // line 2
      const n: number = a;                 // line 3
      b.trim();                            // line 4
      if (typeof b === "string") b.trim(); // line 5
    }
  5. Üben · Aufgabe 5 von 7

    Vervollständigen Sie die Vollständigkeitsprüfung. Tragen Sie den Typ ein, der diese Zeile scheitern lässt, wenn Shape ein neues Mitglied bekommt, das nicht behandelt wird.

    function getArea(shape: Shape): number {
      switch (shape.kind) {
        case "circle":
          return Math.PI * shape.radius ** 2;
        case "square":
          return shape.sideLength ** 2;
        default:
          const _exhaustiveCheck: ____ = shape;
          return _exhaustiveCheck;
      }
    }
    const _exhaustiveCheck: = shape;
  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. Welchen Typ gibt TypeScript x im if?

    function example(x: string | number, y: string | boolean) {
      if (x === y) {
        // what is the type of x here?
      }
    }
  7. Anwenden · Aufgabe 7 von 7

    Kleine Aufgabe. Modellieren Sie das Ergebnis einer Operation als Discriminated Union Result mit zwei Mitgliedern: { kind: "ok"; value: number } und { kind: "err"; message: string }. Schreiben Sie describeResult(r: Result): string mit einem vollständigen switch und toResult(input: unknown): Result, das die Eingabe vor der Nutzung eingrenzt. 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

Drei Arten von Narrowing in einem Programm

show nimmt string | number | null und entfernt mit jeder Prüfung ein Mitglied: zuerst === null, dann typeof. area macht ein switch über die Eigenschaft kind einer Discriminated Union, mit einer never-Prüfung im default-Zweig. toCount nimmt unknown, wie bei Ergebnissen von JSON.parse, und nutzt nur, was es geprüft hat. Führen Sie npx tsc aus: Keine Ausgabe heißt keine Fehler. Dann node main.ts. Fügen Sie Shape einmal | { kind: "triangle"; base: number } hinzu und starten Sie tsc erneut: Die never-Zeile wird gemeldet.

main.ts

// Three kinds of narrowing on one page. tsc follows each check.
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // here shape is the circle member
    case "rect":
      return shape.width * shape.height; // here shape is the rect member
    default: {
      const unhandled: never = shape; // a new kind without a case fails here
      return unhandled;
    }
  }
}

function show(value: string | number | null): string {
  if (value === null) return "nothing"; // === null removes null
  if (typeof value === "number") return value.toFixed(1); // value is number
  return value.toUpperCase(); // only string is left
}

function toCount(input: unknown): number {
  if (typeof input === "number") return input;
  if (typeof input === "string" && input.trim() !== "") return Number(input);
  return 0; // anything else: null, objects, empty text
}

console.log(show(null), show(2.5), show("ok"));
console.log(area({ kind: "circle", radius: 1 }).toFixed(2));
console.log(area({ kind: "rect", width: 2, height: 3 }));
console.log(toCount(JSON.parse("4")), toCount(JSON.parse('"7"')), toCount(JSON.parse("null")));

Ausführen mit

npx tsc
node main.ts

Ausgabe

nothing 2.5 OK
3.14
6
4 7 0
  • Nach return "nothing" ist null weg, und nachdem der typeof-Zweig zurückkehrt, bleibt für toUpperCase nur string übrig.
  • In case "circle" gibt es nur radius; shape.width wäre dort ein Compilerfehler.
  • JSON.parse("null") liefert null, das keine der typeof-Prüfungen annimmt, also gibt toCount 0 zurück, statt abzustürzen.
  • Zur Laufzeit sind die Typen weg: Die Ausgabe ist die desselben JavaScript.
Ä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

null ausschließen

formatAmount nimmt number | string | null. Text behandelt die Funktion schon, aber tsc meldet error TS18047: 'amount' is possibly 'null', und formatAmount(null) stürzt ab. Lassen Sie sie für null "no amount" zurückgeben und behalten Sie die übrigen Ergebnisse bei. Starten Sie die Tests: Der erste ist die Typprüfung.

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 Meldung zeigt auf das letzte return: Nach dem typeof-Zweig ist amount dort noch number | null.

  2. Hinweis 2

    Eine Gleichheitsprüfung entfernt null: if (amount === null) { … } am Anfang der Funktion.

  3. Hinweis 3

    Nehmen Sie dafür nicht typeof amount === "object": Das klappt hier nur zufällig, weil typeof null "object" ist.

Eine Lösung zeigen

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

export function formatAmount(amount: number | string | null): string {
  if (amount === null) {
    return "no amount";
  }
  if (typeof amount === "string") {
    return Number(amount).toFixed(2) + " EUR";
  }
  return amount.toFixed(2) + " EUR";
}
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 function formatAmount(amount: number | string | null): string {
  if (typeof amount === "string") {
    return Number(amount).toFixed(2) + " EUR";
  }
  return amount.toFixed(2) + " EUR";
}

main.test.ts

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

test('formatAmount(4.5) ist "4.50 EUR"', () => {
  assert.equal(formatAmount(4.5), '4.50 EUR', 'formatAmount(4.5) sollte "4.50 EUR" sein');
});

test('formatAmount("12") ist "12.00 EUR"', () => {
  assert.equal(formatAmount('12'), '12.00 EUR', 'formatAmount("12") sollte "12.00 EUR" sein');
});

test('formatAmount(null) ist "no amount"', () => {
  assert.equal(formatAmount(null), 'no amount', 'formatAmount(null) sollte "no amount" 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

Übung 2 von 2

Die neue Zahlungsart behandeln

Payment hat ein drittes Mitglied bekommen, { method: "invoice"; days: number }, und tsc meldet jetzt die never-Zeile in describePayment. Ergänzen Sie den fehlenden Fall, sodass eine Rechnung über 30 Tage "invoice, due in 30 days" ergibt. Behalten Sie die never-Prüfung im default-Zweig.

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 Meldung sagt, welches Mitglied im default-Zweig ankommt: { method: "invoice"; days: number; } ist never nicht zuweisbar.

  2. Hinweis 2

    Fügen Sie vor default ein case "invoice": ein. Darin ist p das invoice-Mitglied, also ist p.days eine Zahl.

  3. Hinweis 3

    return "invoice, due in " + p.days + " days";

Eine Lösung zeigen

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

export type Payment =
  | { method: "card"; last4: string }
  | { method: "paypal"; email: string }
  | { method: "invoice"; days: number };

export function describePayment(p: Payment): string {
  switch (p.method) {
    case "card":
      return "card ending " + p.last4;
    case "paypal":
      return "PayPal " + p.email;
    case "invoice":
      return "invoice, due in " + p.days + " days";
    default: {
      const unhandled: never = p;
      return unhandled;
    }
  }
}
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 Payment =
  | { method: "card"; last4: string }
  | { method: "paypal"; email: string }
  | { method: "invoice"; days: number };

export function describePayment(p: Payment): string {
  switch (p.method) {
    case "card":
      return "card ending " + p.last4;
    case "paypal":
      return "PayPal " + p.email;
    default: {
      const unhandled: never = p;
      return unhandled;
    }
  }
}

main.test.ts

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

test('Eine Kartenzahlung nennt die letzten vier Ziffern', () => {
  assert.equal(describePayment({method: 'card', last4: '4242'}), 'card ending 4242', 'die Kartenzahlung sollte "card ending 4242" ergeben');
});

test('Eine PayPal-Zahlung nennt die E-Mail-Adresse', () => {
  assert.equal(describePayment({method: 'paypal', email: 'ada@example.com'}), 'PayPal ada@example.com', 'die PayPal-Zahlung sollte "PayPal ada@example.com" ergeben');
});

test('Eine Rechnung nennt die Tage bis zur Fälligkeit', () => {
  assert.equal(describePayment({method: 'invoice', days: 30}), 'invoice, due in 30 days', 'die Rechnung sollte "invoice, due in 30 days" ergeben');
});

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

Mit typeof "object" null ausschließen wollen

function count(items: string[] | null): number {
  if (typeof items === "object") {
    return items.length;
  }
  return 0;
}

console.log(count(["a", "b"]));

Was tsc oder Node.js ausgibt

main.ts(3,12): error TS18047: 'items' is possibly 'null'.

Warum, und die Lösung

typeof null ist in JavaScript "object", also lässt die Prüfung string[] und null übrig, und tsc sagt das. Vergleichen Sie stattdessen mit null: if (items !== null), oder items != null, um auch undefined zu entfernen. Eine Truthiness-Prüfung, if (items), klappt hier auch, würde in anderen Unions aber auch einen leeren String oder 0 überspringen.

Eine Eigenschaft lesen, die nur ein Mitglied hat

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(shape: Shape): number {
  return Math.PI * shape.radius ** 2;
}

console.log(area({ kind: "circle", radius: 1 }));

Was tsc oder Node.js ausgibt

main.ts(6,26): error TS2339: Property 'radius' does not exist on type 'Shape'.

Warum, und die Lösung

shape könnte das Quadrat sein, und das hat kein radius; eine Union erlaubt nur, was jedes Mitglied erlaubt. Prüfen Sie zuerst die Diskriminante: if (shape.kind === "circle") return Math.PI * shape.radius ** 2; und behandeln Sie dann "square". Ein switch über shape.kind mit never-Prüfung im default lässt tsc Sie an jede noch nicht behandelte Art erinnern.

Eine Eigenschaft mit Punkt statt mit in prüfen

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Animal = Fish | Bird;

function move(animal: Animal) {
  if (animal.swim) {
    animal.swim();
  }
}

move({ swim: () => console.log("swimming") });

Was tsc oder Node.js ausgibt

main.ts(6,14): error TS2339: Property 'swim' does not exist on type 'Animal'.

Warum, und die Lösung

In JavaScript prüft man eine Methode oft mit if (animal.swim), doch TypeScript lehnt das Lesen von swim ab, solange nicht feststeht, dass animal ein Fish ist: Bird hat kein swim. Der in-Operator ist die Prüfung, die TypeScript versteht: if ("swim" in animal) grenzt animal auf Fish ein, und animal.swim() kompiliert. Im else-Zweig ist animal ein Bird.

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

Eine Union erlaubt nur, was jedes Mitglied erlaubt

Ein Wert vom Typ string | number kann beides sein, darum erlaubt TypeScript nur Operationen, die für jedes Mitglied der Union gültig sind. Für eine String-Methode grenzen Sie zuerst ein: Sie schreiben eine Prüfung, und tsc folgt Ihrem Kontrollfluss und gibt dem Wert in jedem Zweig einen genaueren Typ. Das Handbuch nennt so eine Prüfung einen Type Guard. typeof, ===, !== und != null, in und instanceof grenzen ein, ebenso ein frühes return: Nach if (typeof x === "number") return … ist x im Rest der Funktion ein string. Denken Sie daran: typeof null ist "object", also schließt typeof x === "object" null nicht aus. Spätere Lektionen behandeln jede Prüfung einzeln.

Discriminated Unions und never

Geben Sie jedem Mitglied einer Union eine gemeinsame Eigenschaft mit Literaltyp, etwa kind: "circle" oder kind: "square". Die Prüfung dieser Eigenschaft grenzt das ganze Objekt auf ein Mitglied ein, also dürfen Sie in case "circle" shape.radius lesen. In einem switch über kind bekommt der default-Zweig alles, was übrig bleibt; sind alle Fälle behandelt, ist das never, der Typ ohne Werte. Weisen Sie den Wert dort einer Variablen vom Typ never zu, wird aus „Fall vergessen“ ein Compilerfehler wie Type 'Triangle' is not assignable to type 'never', sobald jemand ein neues Mitglied hinzufügt.

unknown an der Grenze, vor der Nutzung eingegrenzt

Modul B2 hat den Unterschied gezeigt: any schaltet die Prüfung ab, also kompiliert jeder Eigenschaftszugriff und jeder Aufruf; unknown nimmt ebenfalls jeden Wert an, erlaubt aber nichts, bis Sie ihn eingrenzen. Erst Narrowing macht unknown nutzbar. In if (typeof input === "number") ist input eine Zahl, und Sie dürfen input.toFixed(2) aufrufen. Geben Sie Daten von außerhalb Ihres Programms, etwa Ergebnisse von JSON.parse oder Request-Bodys, den Typ unknown und grenzen Sie sie einmal ein, an der Grenze. Mit any fließt ein JSON-String "3" ungeprüft in Rechnungen, und aus "3" + 1 wird still "31".

Quellen

Zuletzt geprüft am 3. Oktober 2026