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


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. 🙂


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.
Es scheint, als wäre "union" ein falsch angewandter Begriff. Ich würde sagen, dass der Begriff der Mengenlehre, der zutreffen sollte, "intersection" (Schnittmenge) ist. Das heißt, "weight" ist das Gemeinsame zwischen "Dog" und "Cat". In der Mengenlehre würden wir das als die Schnittmenge bezeichnen.
Denken Sie daran, wir konzentrieren uns auf die Menge der Werte, die ein bestimmter Typ enthalten kann.
Wenn wir sagen
let a: Dog | Catdeklarieren wir, dassajeden Wert in der Mengenvereinigung von gültigen Dog-Werten und gültigen Cat-Werten enthalten kann.Aber wenn es um Eigenschaften geht, auf die TS über
azugreifen lässt, ist dies auf diejenigen beschränkt, von denen TS sicher weiß, dass sie vorhanden sein werden, was, egal wie verwirrend das sein mag, die Schnittmenge der Eigenschaften von Dog und Cat ist.Das ist vielleicht für manche erwähnenswert: Verwenden Sie ein Enum für
ANIMAL_TYPE. Sie müssen daran denken, dem Enum weitere Tiere hinzuzufügen, zusätzlich zur Erstellung der Klasse (oder des Typs), aber Sie vermeiden Tippfehler und gewinnen an Typisierung der Eigenschaft, was meiner Meinung nach ein großer Gewinn ist.Guter Punkt! Ich stimme zu.
Nicht nötig, da TypeScript automatisch sicherstellt, dass der String genau der richtige Wert ist. Tippfehler werden also auch unmöglich.
Ein Unterschied zwischen Enums und Unions ist, dass Enums zu Laufzeitobjekten kompiliert werden, während Typ-Vereinigungen herausgefiltert werden. Keine Performance-Überlegung, aber etwas, das man sich bewusst sein sollte.
In diesem Fall erledigt ein String-Union-Typ seine Aufgabe gut, da TypeScript Intellisense liefert und auch beschwert, wenn es in Zukunft zu einem Typenkonflikt kommt. Aber Enums ersparen Ihnen definitiv die Mühe, einzelne Verwendungen manuell zu aktualisieren.
Ich schwöre, es gab eine frühere Version von TypeScript, bei der discriminated unions mit Enums Typen nicht korrekt verengten, aber es scheint jetzt gut zu funktionieren.
Vielen Dank SO VIEL für das Schreiben dieses Artikels. Ich begann, meine neue Nebenprojekt mit TS einzurichten zu bereuen und schlug mich mit genau diesem Problem herum, als Ihr Artikel in meinem Feed auftauchte. Sie haben es großartig geschafft, ein komplexes Thema in verständliche Abschnitte und umsetzbare Lösungen zu zerlegen.
Danke für die Infos, Adam.
Sie erwähnen, dass
Das Konzept der discriminated unions ist, wie TypeScript zwischen diesen Objekten unterscheidet und dies auf eine Weise tut, die extrem gut skaliert, selbst mit größeren Objektmengen.
Nur um klarzustellen: discriminated unions sind ein Konzept, das in TypeScript durch die Verwendung von Unions, Diskriminator-Eigenschaften, Logik, die Diskriminatoren prüft, und statischer Kontrollflussanalyse ausgedrückt werden kann. TypeScript unterstützt das Konzept der discriminated unions nicht ganz als Sprachfunktion, sondern ermöglicht es dem Benutzer, dieses Konzept als Programmiermuster zu unterstützen.
Ich wollte nur klarstellen, was TS Ihnen hilft und was nicht. Es ist nützlich, dass Leser die Vorteile verstehen, die mit der vollständigen Unterstützung als Sprachfunktion einhergehen, und die Gefahren, die mit dem Mangel an Unterstützung verbunden sind.
Sie müssen sicherstellen, dass Ihre Logik, die Union-Typen behandelt, mit Ihren Union-Typen skaliert. In Sprachen, die Disjoint Unions nativ unterstützen, erhalten Sie im Allgemeinen auch Exhaustive Pattern Matching als Funktion. Dies ermöglicht Ihnen sicherzustellen, dass Sie bei der Erweiterung Ihrer Union-Typen auch Ihre Logik erweitern, die diese Union-Typen behandelt. Ein ähnliches Muster kann in TypeScript ausgedrückt werden.
Es scheint wirklich, als wäre es viel besser, einen Laufzeitoperator analog zu typeof und instanceof für TypeScript-Schnittstellen zu haben, anstatt ein hartkodiertes Feld manuell zu pflegen.
Hallo! Wirklich schöner Artikel, vielen Dank :)
In der Schlussfolgerung wird erwähnt
Das ist falsch, da animal eine Cat oder ein Dog oder beides sein kann. Zum Beispiel produziert das Folgende keine Fehler