Das vorherige Kapitel untersuchte, wie TypeScript-Enums funktionieren. In diesem Kapitel betrachten wir Alternativen zu Enums.
Ein Enum ordnet Mitgliedsnamen Mitgliedswerten zu. Wenn wir die Indirektion nicht benötigen oder wollen, können wir eine Vereinigung von sogenannten primitiven Literal-Typen verwenden – einen pro Wert. Bevor wir ins Detail gehen, müssen wir etwas über primitive Literal-Typen lernen.
Kurze Wiederholung: Wir können Typen als Mengen von Werten betrachten.
Ein Singleton-Typ ist ein Typ mit einem Element. Primitive Literal-Typen sind Singleton-Typen
type UndefinedLiteralType = undefined;
type NullLiteralType = null;
type BooleanLiteralType = true;
type NumericLiteralType = 123;
type BigIntLiteralType = 123n; // --target must be ES2020+
type StringLiteralType = 'abc';UndefinedLiteralType ist der Typ mit dem einzelnen Element undefined, usw.
Es ist wichtig, sich der beiden Sprachebenen bewusst zu sein, die hier im Spiel sind (wir sind diesen Ebenen bereits früher in diesem Buch begegnet). Betrachten wir die folgende Variablendeklaration
const abc: 'abc' = 'abc';'abc' repräsentiert einen Typ (einen String-Literal-Typ).'abc' repräsentiert einen Wert.Zwei Anwendungsfälle für primitive Literal-Typen sind
Überladung basierend auf String-Parametern, was es dem ersten Argument des folgenden Methodenaufrufs ermöglicht, den Typ des zweiten Arguments zu bestimmen
elem.addEventListener('click', myEventHandler);Wir können eine Vereinigung von primitiven Literal-Typen verwenden, um einen Typ zu definieren, indem wir seine Mitglieder aufzählen
type IceCreamFlavor = 'vanilla' | 'chocolate' | 'strawberry';Lesen Sie weiter für weitere Informationen über den zweiten Anwendungsfall.
Wir beginnen mit einem Enum und wandeln es in eine Vereinigung von String-Literal-Typen um.
enum NoYesEnum {
No = 'No',
Yes = 'Yes',
}
function toGerman1(value: NoYesEnum): string {
switch (value) {
case NoYesEnum.No:
return 'Nein';
case NoYesEnum.Yes:
return 'Ja';
}
}
assert.equal(toGerman1(NoYesEnum.No), 'Nein');
assert.equal(toGerman1(NoYesEnum.Yes), 'Ja');NoYesStrings ist die Union-Typ-Version von NoYesEnum
type NoYesStrings = 'No' | 'Yes';
function toGerman2(value: NoYesStrings): string {
switch (value) {
case 'No':
return 'Nein';
case 'Yes':
return 'Ja';
}
}
assert.equal(toGerman2('No'), 'Nein');
assert.equal(toGerman2('Yes'), 'Ja');Der Typ NoYesStrings ist die Vereinigung der String-Literal-Typen 'No' und 'Yes'. Der Union-Typ-Operator | ist mit dem mengentheoretischen Vereinigungsoperator ∪ verwandt.
Der folgende Code zeigt, dass Vollständigkeitsprüfungen für Vereinigungen von String-Literal-Typen funktionieren
// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'. (2366)
function toGerman3(value: NoYesStrings): string {
switch (value) {
case 'Yes':
return 'Ja';
}
}Wir haben den Fall für 'No' vergessen, und TypeScript warnt uns, dass die Funktion Werte zurückgeben kann, die keine Strings sind.
Wir hätten die Vollständigkeit auch expliziter prüfen können
class UnsupportedValueError extends Error {
constructor(value: never) {
super('Unsupported value: ' + value);
}
}
function toGerman4(value: NoYesStrings): string {
switch (value) {
case 'Yes':
return 'Ja';
default:
// @ts-expect-error: Argument of type '"No"' is not
// assignable to parameter of type 'never'. (2345)
throw new UnsupportedValueError(value);
}
}Nun warnt uns TypeScript, dass wir den default-Fall erreichen, wenn value 'No' ist.
Mehr Informationen zur Vollständigkeitsprüfung
Weitere Informationen zu diesem Thema finden Sie in §12.7.2.2 „Schutz vor vergessenen Fällen durch Vollständigkeitsprüfungen“.
Ein Nachteil von String-Literal-Vereinigungen ist, dass Nicht-Mitgliedswerte fälschlicherweise für Mitglieder gehalten werden können
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;Das ist logisch, da das spanische 'no' und das englische 'no' derselbe Wert sind. Das eigentliche Problem ist, dass es keine Möglichkeit gibt, ihnen unterschiedliche Identitäten zu geben.
LogLevelAnstelle von Vereinigungen von String-Literal-Typen können wir auch Vereinigungen von Symbol-Singleton-Typen verwenden. Beginnen wir diesmal mit einem anderen Enum
enum LogLevel {
off = 'off',
info = 'info',
warn = 'warn',
error = 'error',
}In eine Vereinigung von Symbol-Singleton-Typen übersetzt sieht es wie folgt aus
const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');
// %inferred-type: unique symbol | unique symbol |
// unique symbol | unique symbol
type LogLevel =
| typeof off
| typeof info
| typeof warn
| typeof error
;Warum brauchen wir hier typeof? off usw. sind Werte und können nicht in Typgleichungen vorkommen. Der Typoperator typeof behebt dieses Problem, indem er Werte in Typen umwandelt.
Betrachten wir zwei Variationen des vorherigen Beispiels.
Können wir die Symbole inline definieren (anstatt auf separate const-Deklarationen zu verweisen)? Leider muss der Operand des Typoperators typeof ein Bezeichner oder ein "Pfad" von Bezeichnern sein, die durch Punkte getrennt sind. Daher ist diese Syntax illegal
type LogLevel = typeof Symbol('off') | ···let statt constKönnen wir let anstelle von const verwenden, um die Variablen zu deklarieren? (Das ist nicht unbedingt eine Verbesserung, aber dennoch eine interessante Frage.)
Das können wir nicht, da wir die engeren Typen benötigen, die TypeScript für const-deklarierte Variablen ableitet
// %inferred-type: unique symbol
const constSymbol = Symbol('constSymbol');
// %inferred-type: symbol
let letSymbol1 = Symbol('letSymbol1');Mit let wäre LogLevel einfach ein Alias für symbol gewesen.
const-Assertions lösen normalerweise diese Art von Problem. Aber sie funktionieren in diesem Fall nicht
// @ts-expect-error: A 'const' assertions can only be applied to references to enum
// members, or string, number, boolean, array, or object literals. (1355)
let letSymbol2 = Symbol('letSymbol2') as const;LogLevel in einer FunktionDie folgende Funktion wandelt Mitglieder von LogLevel in Strings um
function getName(logLevel: LogLevel): string {
switch (logLevel) {
case off:
return 'off';
case info:
return 'info';
case warn:
return 'warn';
case error:
return 'error';
}
}
assert.equal(
getName(warn), 'warn');Wie schneiden sich die beiden Ansätze?
Erinnern Sie sich an dieses Beispiel, bei dem das spanische 'no' mit dem englischen 'no' verwechselt wurde
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;Wenn wir Symbole verwenden, haben wir dieses Problem nicht
const spanishNo = Symbol('no');
const spanishSí = Symbol('sí');
type Spanish = typeof spanishNo | typeof spanishSí;
const englishNo = Symbol('no');
const englishYes = Symbol('yes');
type English = typeof englishNo | typeof englishYes;
const spanishWord: Spanish = spanishNo;
// @ts-expect-error: Type 'unique symbol' is not assignable to type 'English'. (2322)
const englishWord: English = spanishNo;Union-Typen und Enums haben einige Gemeinsamkeiten
Aber sie unterscheiden sich auch. Nachteile von Vereinigungen von Symbol-Singleton-Typen sind
Vorteile von Vereinigungen von Symbol-Singleton-Typen sind
Diskriminierte Vereinigungen sind mit algebraischen Datentypen in funktionalen Programmiersprachen verwandt.
Um zu verstehen, wie sie funktionieren, betrachten wir die Datenstruktur Syntaxbaum, die Ausdrücke wie diese repräsentiert
1 + 2 + 3
Ein Syntaxbaum ist entweder
Nächste Schritte
Dies ist eine typische objektorientierte Implementierung eines Syntaxbaums
// Abstract = can’t be instantiated via `new`
abstract class SyntaxTree1 {}
class NumberValue1 extends SyntaxTree1 {
constructor(public numberValue: number) {
super();
}
}
class Addition1 extends SyntaxTree1 {
constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
super();
}
}SyntaxTree1 ist die Oberklasse von NumberValue1 und Addition1. Das Schlüsselwort public ist eine syntaktische Zucker für
.numberValuenumberValueDies ist ein Beispiel für die Verwendung von SyntaxTree1
const tree = new Addition1(
new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3), // trailing comma
), // trailing comma
);Hinweis: Nachgestellte Kommas in Argumentlisten sind in JavaScript seit ECMAScript 2016 erlaubt.
Wenn wir den Syntaxbaum über einen Union-Typ definieren (Zeile A), benötigen wir keine objektorientierte Vererbung
class NumberValue2 {
constructor(public numberValue: number) {}
}
class Addition2 {
constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}
type SyntaxTree2 = NumberValue2 | Addition2; // (A)Da NumberValue2 und Addition2 keine Oberklasse haben, müssen sie super() in ihren Konstruktoren nicht aufrufen.
Interessanterweise erstellen wir Bäume auf die gleiche Weise wie zuvor
const tree = new Addition2(
new NumberValue2(1),
new Addition2(
new NumberValue2(2),
new NumberValue2(3),
),
);Schließlich kommen wir zu diskriminierten Vereinigungen. Dies sind die Typdefinitionen für SyntaxTree3
interface NumberValue3 {
kind: 'number-value';
numberValue: number;
}
interface Addition3 {
kind: 'addition';
operand1: SyntaxTree3;
operand2: SyntaxTree3;
}
type SyntaxTree3 = NumberValue3 | Addition3;Wir sind von Klassen zu Interfaces und damit von Instanzen von Klassen zu einfachen Objekten gewechselt.
Die Interfaces einer diskriminierten Vereinigung müssen mindestens eine gemeinsame Eigenschaft haben, und diese Eigenschaft muss für jede einzelne einen anderen Wert haben. Diese Eigenschaft wird als Diskriminante oder Tag bezeichnet. Die Diskriminante von SyntaxTree3 ist .kind. Ihre Typen sind String-Literal-Typen.
Vergleichen Sie
Dies ist ein Objekt, das SyntaxTree3 entspricht
const tree: SyntaxTree3 = { // (A)
kind: 'addition',
operand1: {
kind: 'number-value',
numberValue: 1,
},
operand2: {
kind: 'addition',
operand1: {
kind: 'number-value',
numberValue: 2,
},
operand2: {
kind: 'number-value',
numberValue: 3,
},
}
};Wir brauchen die Typannotation in Zeile A nicht, aber sie hilft sicherzustellen, dass die Daten die korrekte Struktur haben. Wenn wir dies hier nicht tun, werden wir später auf Probleme stoßen.
Im nächsten Beispiel ist der Typ von tree eine diskriminierte Vereinigung. Jedes Mal, wenn wir seine Diskriminante überprüfen (Zeile C), aktualisiert TypeScript seinen statischen Typ entsprechend
function getNumberValue(tree: SyntaxTree3) {
// %inferred-type: SyntaxTree3
tree; // (A)
// @ts-expect-error: Property 'numberValue' does not exist on type 'SyntaxTree3'.
// Property 'numberValue' does not exist on type 'Addition3'.(2339)
tree.numberValue; // (B)
if (tree.kind === 'number-value') { // (C)
// %inferred-type: NumberValue3
tree; // (D)
return tree.numberValue; // OK!
}
return null;
}In Zeile A haben wir die Diskriminante .kind noch nicht überprüft. Daher ist der aktuelle Typ von tree immer noch SyntaxTree3, und wir können die Eigenschaft .numberValue in Zeile B nicht zugreifen (da nur einer der Typen der Vereinigung diese Eigenschaft hat).
In Zeile D weiß TypeScript, dass .kind 'number-value' ist, und kann daher den Typ NumberValue3 für tree ableiten. Deshalb ist der Zugriff auf .numberValue in der nächsten Zeile diesmal in Ordnung.
Wir schließen diesen Schritt mit einem Beispiel ab, wie Funktionen für diskriminierte Vereinigungen implementiert werden.
Wenn es eine Operation gibt, die auf Mitglieder aller Untertypen angewendet werden kann, unterscheiden sich die Ansätze für Klassen und diskriminierte Vereinigungen
Das folgende Beispiel demonstriert den funktionalen Ansatz. Die Diskriminante wird in Zeile A untersucht und bestimmt, welcher der beiden switch-Fälle ausgeführt wird.
function syntaxTreeToString(tree: SyntaxTree3): string {
switch (tree.kind) { // (A)
case 'addition':
return syntaxTreeToString(tree.operand1)
+ ' + ' + syntaxTreeToString(tree.operand2);
case 'number-value':
return String(tree.numberValue);
}
}
assert.equal(syntaxTreeToString(tree), '1 + 2 + 3');Beachten Sie, dass TypeScript Vollständigkeitsprüfungen für diskriminierte Vereinigungen durchführt: Wenn wir einen Fall vergessen, wird uns TypeScript warnen.
Dies ist die objektorientierte Version des vorherigen Codes
abstract class SyntaxTree1 {
// Abstract = enforce that all subclasses implement this method:
abstract toString(): string;
}
class NumberValue1 extends SyntaxTree1 {
constructor(public numberValue: number) {
super();
}
toString(): string {
return String(this.numberValue);
}
}
class Addition1 extends SyntaxTree1 {
constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
super();
}
toString(): string {
return this.operand1.toString() + ' + ' + this.operand2.toString();
}
}
const tree = new Addition1(
new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3),
),
);
assert.equal(tree.toString(), '1 + 2 + 3');Jeder Ansatz bewältigt eine Art von Erweiterbarkeit gut
Mit dem objektorientierten Ansatz müssen wir jede Klasse ändern, wenn wir eine neue Operation hinzufügen wollen. Das Hinzufügen eines neuen Typs erfordert jedoch keine Änderungen am bestehenden Code.
Mit dem funktionalen Ansatz müssen wir jede Funktion ändern, wenn wir einen neuen Typ hinzufügen wollen. Das Hinzufügen neuer Operationen ist dagegen einfach.
Diskriminierte Vereinigungen und normale Union-Typen haben zwei Gemeinsamkeiten
Die nächsten beiden Unterabschnitte untersuchen zwei Vorteile von diskriminierten Vereinigungen gegenüber normalen Vereinigungen
Bei diskriminierten Vereinigungen erhalten Werte beschreibende Eigenschaftsnamen. Vergleichen wir
Normale Vereinigung
type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;Diskriminierte Vereinigung
interface FileSourceFile {
type: 'FileSourceFile',
nativePath: string,
}
interface FileSourceGenerator {
type: 'FileSourceGenerator',
fileGenerator: FileGenerator,
}
type FileSource2 = FileSourceFile | FileSourceGenerator;Nun wissen die Leser des Quellcodes sofort, was der String ist: ein nativer Pfadname.
Die folgende diskriminierte Vereinigung kann nicht als normale Vereinigung implementiert werden, da wir die Typen der Vereinigung in TypeScript nicht unterscheiden können.
interface TemperatureCelsius {
type: 'TemperatureCelsius',
value: number,
}
interface TemperatureFahrenheit {
type: 'TemperatureFahrenheit',
value: number,
}
type Temperature = TemperatureCelsius | TemperatureFahrenheit;Das folgende Muster zur Implementierung von Enums ist in JavaScript verbreitet
const Color = {
red: Symbol('red'),
green: Symbol('green'),
blue: Symbol('blue'),
};Wir können versuchen, es in TypeScript wie folgt zu verwenden
// %inferred-type: symbol
Color.red; // (A)
// %inferred-type: symbol
type TColor2 = // (B)
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;
function toGerman(color: TColor): string {
switch (color) {
case Color.red:
return 'rot';
case Color.green:
return 'grün';
case Color.blue:
return 'blau';
default:
// No exhaustiveness check (inferred type is not `never`):
// %inferred-type: symbol
color;
// Prevent static error for return type:
throw new Error();
}
}Leider hat jede Eigenschaft von Color den Typ symbol (Zeile A) und TColor (Zeile B) ist ein Alias für symbol. Infolgedessen können wir toGerman() jeden beliebigen Symbol übergeben, und TypeScript wird zur Kompilierzeit keine Beschwerde erheben
assert.equal(
toGerman(Color.green), 'grün');
assert.throws(
() => toGerman(Symbol())); // no static error!Eine const-Assertion hilft oft in dieser Art von Situation, aber nicht dieses Mal
const ConstColor = {
red: Symbol('red'),
green: Symbol('green'),
blue: Symbol('blue'),
} as const;
// %inferred-type: symbol
ConstColor.red;Der einzige Weg, dies zu beheben, sind Konstanten
const red = Symbol('red');
const green = Symbol('green');
const blue = Symbol('blue');
// %inferred-type: unique symbol
red;
// %inferred-type: unique symbol | unique symbol | unique symbol
type TColor2 = typeof red | typeof green | typeof blue;const Color = {
red: 'red',
green: 'green',
blue: 'blue',
} as const; // (A)
// %inferred-type: "red"
Color.red;
// %inferred-type: "red" | "green" | "blue"
type TColor =
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;Wir benötigen as const in Zeile A, damit die Eigenschaften von Color nicht den allgemeineren Typ string haben. Dann hat TColor auch einen Typ, der spezifischer als string ist.
Im Vergleich zur Verwendung eines Objekts mit Symbol-Werten als Enum sind String-Werte
Vorteile
Nachteile
Das folgende Beispiel demonstriert ein von Java inspiriertes Enum-Pattern, das in reinem JavaScript und TypeScript funktioniert
class Color {
static red = new Color();
static green = new Color();
static blue = new Color();
}
// @ts-expect-error: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
switch (color) {
case Color.red:
return 'rot';
case Color.green:
return 'grün';
case Color.blue:
return 'blau';
}
}
assert.equal(toGerman(Color.blue), 'blau');Leider führt TypeScript keine Vollständigkeitsprüfungen durch, weshalb wir in Zeile A einen Fehler erhalten.
Die folgende Tabelle fasst die Eigenschaften von Enums und ihren Alternativen in TypeScript zusammen
| Eindeutig | Namensraum | Iter. | Mitgl. CT | Mitgl. RT | Vollst. | |
|---|---|---|---|---|---|---|
| Zahlen-Enums | - |
✔ |
✔ |
✔ |
- |
✔ |
| String-Enums | ✔ |
✔ |
✔ |
✔ |
- |
✔ |
| String-Vereinigungen | - |
- |
- |
✔ |
- |
✔ |
| Symbol-Vereinigungen | ✔ |
- |
- |
✔ |
- |
✔ |
| Diskrim. Vereinig. | - (1) |
- |
- |
✔ |
- (2) |
✔ |
| Symbol-Eigensch. | ✔ |
✔ |
✔ |
- |
- |
- |
| String-Eigensch. | - |
✔ |
✔ |
✔ |
- |
✔ |
| Enum-Pattern | ✔ |
✔ |
✔ |
✔ |
✔ |
- |
Titel der Tabellenspalten
instanceof.Fußnoten in Tabellenzellen
TColor für ein Objekt-Literal definiert werden kann.