Demystifying TypeScript Discriminated Unions

Avatar of Adam Rackis
Adam Rackis am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

TypeScript ist ein wunderbares Werkzeug zum Schreiben von skalierbarem JavaScript. Es ist mehr oder weniger der De-facto-Standard für das Web, wenn es um große JavaScript-Projekte geht. So hervorragend es auch ist, es gibt einige knifflige Stellen für Ungeübte. Ein solcher Bereich sind TypeScript discriminated unions.

Insbesondere bei diesem Code

interface Cat {
  weight: number;
  whiskers: number;
}
interface Dog {
  weight: number;
  friendly: boolean;
}
let animal: Dog | Cat;

… sind viele Entwickler überrascht (und vielleicht sogar verärgert), wenn sie feststellen, dass beim Aufruf von animal. nur die weight-Eigenschaft gültig ist, aber nicht whiskers oder friendly. Am Ende dieses Beitrags wird dies völlig Sinn ergeben.

Bevor wir eintauchen, machen wir einen kurzen (und notwendigen) Überblick über strukturelles Typing und wie es sich von nominalem Typing unterscheidet. Dies wird unsere Diskussion über TypeScript's discriminated unions gut vorbereiten.

Strukturelles Typing

Der beste Weg, strukturelles Typing einzuführen, ist der Vergleich mit was es nicht ist. Die meisten typisierten Sprachen, die Sie wahrscheinlich verwendet haben, sind nominal typisiert. Betrachten Sie diesen C#-Code (Java oder C++ würden ähnlich aussehen)

class Foo {
  public int x;
}
class Blah {
  public int x;
}

Obwohl Foo und Blah strukturell exakt gleich sind, können sie nicht einander zugewiesen werden. Der folgende Code

Blah b = new Foo();

… erzeugt diesen Fehler

Cannot implicitly convert type 'Foo' to 'Blah'

Die Struktur dieser Klassen ist irrelevant. Eine Variable vom Typ Foo kann nur Instanzen der Klasse Foo (oder deren Unterklassen) zugewiesen werden.

TypeScript arbeitet umgekehrt. TypeScript betrachtet Typen als kompatibel, wenn sie die gleiche Struktur haben – daher der Name, strukturelles Typing. Verstanden?

Daher funktioniert das Folgende ohne Fehler

class Foo {
  x: number = 0;
}
class Blah {
  x: number = 0;
}
let f: Foo = new Blah();
let b: Blah = new Foo();

Typen als Mengen von übereinstimmenden Werten

Lassen Sie uns das noch einmal verdeutlichen. Bei diesem Code

class Foo {
  x: number = 0;
}

let f: Foo;

f ist eine Variable, die jedes Objekt enthält, das der Struktur von Instanzen entspricht, die von der Foo-Klasse erstellt wurden, was in diesem Fall bedeutet, dass eine x-Eigenschaft eine Zahl repräsentiert. Das bedeutet, dass selbst ein einfaches JavaScript-Objekt akzeptiert wird.

let f: Foo;
f = {
  x: 0
}

Vereinigungen (Unions)

Danke, dass Sie bisher bei mir geblieben sind. Kehren wir zum Code vom Anfang zurück

interface Cat {
  weight: number;
  whiskers: number;
}
interface Dog {
  weight: number;
  friendly: boolean;
}

Wir wissen, dass dies

let animal: Dog;

animal zu jedem Objekt macht, das die gleiche Struktur wie die Dog-Schnittstelle hat. Was bedeutet dann das Folgende?

let animal: Dog | Cat;

Dies typisiert animal als jedes Objekt, das der Dog-Schnittstelle entspricht, oder jedes Objekt, das der Cat-Schnittstelle entspricht.

Warum erlaubt uns animal – so wie es jetzt existiert – nur den Zugriff auf die weight-Eigenschaft? Einfach ausgedrückt: Weil TypeScript nicht weiß, welcher Typ es ist. TypeScript weiß, dass animal entweder ein Dog oder eine Cat sein muss, aber es könnte eines von beiden sein (oder beides gleichzeitig, aber bleiben wir einfach). Wir würden wahrscheinlich Laufzeitfehler bekommen, wenn wir auf die friendly-Eigenschaft zugreifen dürften, die Instanz aber eine Cat anstelle eines Dog wäre. Ebenso für die whiskers-Eigenschaft, wenn das Objekt ein Dog wäre.

Typ-Vereinigungen sind Vereinigungen von gültigen Werten und keine Vereinigungen von Eigenschaften. Entwickler schreiben oft etwas wie dies

let animal: Dog | Cat;

…und erwarten, dass animal die Vereinigung von Dog- und Cat-Eigenschaften hat. Aber das ist wieder ein Fehler. Dies spezifiziert animal als einen Wert, der der Vereinigung von gültigen Dog-Werten und gültigen Cat-Werten entspricht. Aber TypeScript erlaubt Ihnen nur den Zugriff auf Eigenschaften, von denen es weiß, dass sie vorhanden sind. Vorerst bedeutet dies Eigenschaften aller Typen in der Vereinigung.

Verengung (Narrowing)

Im Moment haben wir dies

let animal: Dog | Cat;

Wie behandeln wir animal richtig als Dog, wenn es ein Dog ist, und greifen auf Eigenschaften der Dog-Schnittstelle zu, und ebenso, wenn es eine Cat ist? Vorerst können wir den in-Operator verwenden. Das ist ein altmodischer JavaScript-Operator, den Sie wahrscheinlich nicht sehr oft sehen, aber er erlaubt uns im Wesentlichen zu testen, ob eine Eigenschaft in einem Objekt vorhanden ist. So:

let o = { a: 12 };

"a" in o; // true
"x" in o; // false

Es stellt sich heraus, dass TypeScript tief in den in-Operator integriert ist. Sehen wir, wie:

let animal: Dog | Cat = {} as any;

if ("friendly" in animal) {
  console.log(animal.friendly);
} else {
  console.log(animal.whiskers);
}

Dieser Code produziert keine Fehler. Innerhalb des if-Blocks weiß TypeScript, dass eine friendly-Eigenschaft existiert, und wandelt animal daher in einen Dog um. Und innerhalb des else-Blocks behandelt TypeScript animal ähnlich wie eine Cat. Sie können dies sogar sehen, wenn Sie mit der Maus über das Tierobjekt in diesen Blöcken in Ihrem Code-Editor fahren

Showing a tooltip open on top of a a TypeScript discriminated unions example that shows `let animal: Dog`.
Showing a tooltip open on top of a a TypeScript discriminated union example that shows `let animal: Cat`.

Diskriminierte Vereinigungen

Sie würden erwarten, dass der Blogbeitrag hier endet, aber leider ist die Verengung von Typ-Vereinigungen durch Prüfung auf das Vorhandensein von Eigenschaften unglaublich begrenzt. Es funktionierte gut für unsere trivialen Dog- und Cat-Typen, aber die Dinge können leicht komplizierter und zerbrechlicher werden, wenn wir mehr Typen haben und mehr Überlappungen zwischen diesen Typen.

Hier kommen discriminated unions ins Spiel. Wir behalten alles wie zuvor, fügen aber jeder Art eine Eigenschaft hinzu, deren einzige Aufgabe es ist, zwischen den Typen zu unterscheiden (oder zu "diskriminieren")

interface Cat {
  weight: number;
  whiskers: number;
  ANIMAL_TYPE: "CAT";
}
interface Dog {
  weight: number;
  friendly: boolean;
  ANIMAL_TYPE: "DOG";
}

Beachten Sie die ANIMAL_TYPE-Eigenschaft bei beiden Typen. Verwechseln Sie dies nicht mit einem String mit zwei verschiedenen Werten; dies ist ein Literal-Typ. ANIMAL_TYPE: "CAT"; bedeutet einen Typ, der exakt den String "CAT" enthält und nichts anderes.

Und jetzt wird unsere Überprüfung etwas zuverlässiger

let animal: Dog | Cat = {} as any;

if (animal.ANIMAL_TYPE === "DOG") {
  console.log(animal.friendly);
} else {
  console.log(animal.whiskers);
}

Vorausgesetzt, jeder Typ, der an der Vereinigung beteiligt ist, hat einen eindeutigen Wert für die ANIMAL_TYPE-Eigenschaft, wird diese Überprüfung narrensicher.

Der einzige Nachteil ist, dass Sie jetzt eine neue Eigenschaft zu handhaben haben. Jedes Mal, wenn Sie eine Instanz eines Dog oder einer Cat erstellen, müssen Sie den einen korrekten Wert für ANIMAL_TYPE angeben. Aber keine Sorge, wenn Sie es vergessen, denn TypeScript wird Sie daran erinnern. 🙂

Showing the TypeScript discriminated union for a createDog function that returns weight and friendly properties.
Screenshot of TypeScript displaying a warning in the code editor as a result of not providing a single value for the ANIMAL_TYPE property.


Weitere Lektüre

Wenn Sie mehr erfahren möchten, empfehle ich die Dokumentation von TypeScript zur Verengung. Diese bietet eine tiefere Abdeckung dessen, was wir hier besprochen haben. Innerhalb dieses Links gibt es einen Abschnitt über Typ-Prädikate. Diese erlauben Ihnen, eigene, benutzerdefinierte Prüfungen zu definieren, um Typen zu verengen, ohne Typ-Diskriminatoren verwenden zu müssen und ohne sich auf das in-Schlüsselwort zu verlassen.

Fazit

Am Anfang dieses Artikels habe ich gesagt, dass es Sinn ergeben würde, warum weight die einzige zugängliche Eigenschaft im folgenden Beispiel ist

interface Cat {
  weight: number;
  whiskers: number;
}
interface Dog {
  weight: number;
  friendly: boolean;
}
let animal: Dog | Cat;

Was wir gelernt haben, ist, dass TypeScript nur weiß, dass animal entweder ein Dog oder eine Cat sein könnte, aber nicht beides. Daher erhalten wir nur weight, die einzige gemeinsame Eigenschaft zwischen beiden.

Das Konzept der diskriminierten Vereinigungen ist, wie TypeScript zwischen diesen Objekten unterscheidet und dies auf eine Weise tut, die extrem gut skaliert, selbst mit größeren Objektmengen. Daher mussten wir eine neue ANIMAL_TYPE-Eigenschaft bei beiden Typen erstellen, die einen einzelnen literalen Wert enthält, den wir zum Abgleichen verwenden können. Sicher, es ist eine weitere Sache, die man verfolgen muss, aber es liefert auch zuverlässigere Ergebnisse – was wir sowieso von TypeScript wollen.