上一章探讨了 TypeScript 枚举的工作原理。在本章中,我们将介绍枚举的替代方案。
枚举将成员名称映射到成员值。如果我们不需要或不想进行间接映射,可以使用所谓的*原始字面量类型*的联合——每个值对应一个类型。在深入探讨之前,我们需要了解原始字面量类型。
快速回顾:我们可以将类型视为值的集合。
*单例类型*是只有一个元素的类型。原始字面量类型是单例类型
type UndefinedLiteralType = undefined;
type NullLiteralType = null;
type BooleanLiteralType = true;
type NumericLiteralType = 123;
type BigIntLiteralType = 123n; // --target must be ES2020+
type StringLiteralType = 'abc';
UndefinedLiteralType
是只有一个元素 undefined
的类型,等等。
重要的是要注意这里涉及的两个语言级别(我们已经在本书前面遇到过这些级别)。请考虑以下变量声明
: 'abc' = 'abc'; const abc
'abc'
表示一个类型(字符串字面量类型)。'abc'
表示一个值。原始字面量类型的两个用例是
字符串参数重载,它允许以下方法调用的第一个参数确定第二个参数的类型
.addEventListener('click', myEventHandler); elem
我们可以使用原始字面量类型的联合通过枚举其成员来定义类型
type IceCreamFlavor = 'vanilla' | 'chocolate' | 'strawberry';
继续阅读以获取有关第二个用例的更多信息。
我们将从一个枚举开始,并将其转换为字符串字面量类型的联合。
enum NoYesEnum {= 'No',
No = 'Yes',
Yes
}function toGerman1(value: NoYesEnum): string {
switch (value) {
.No:
case NoYesEnum'Nein';
return .Yes:
case NoYesEnum'Ja';
return
}
}.equal(toGerman1(NoYesEnum.No), 'Nein');
assert.equal(toGerman1(NoYesEnum.Yes), 'Ja'); assert
NoYesStrings
是 NoYesEnum
的联合类型版本
type NoYesStrings = 'No' | 'Yes';
function toGerman2(value: NoYesStrings): string {
switch (value) {
'No':
case 'Nein';
return 'Yes':
case 'Ja';
return
}
}.equal(toGerman2('No'), 'Nein');
assert.equal(toGerman2('Yes'), 'Ja'); assert
类型 NoYesStrings
是字符串字面量类型 'No'
和 'Yes'
的联合。联合类型运算符 |
与集合论中的并集运算符 ∪
相关。
以下代码演示了穷举性检查适用于字符串字面量类型的联合
// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'. (2366)
function toGerman3(value: NoYesStrings): string {
switch (value) {
'Yes':
case 'Ja';
return
} }
我们忘记了 'No'
的情况,TypeScript 警告我们该函数可能会返回非字符串值。
我们也可以更明确地检查穷举性
class UnsupportedValueError extends Error {constructor(value: never) {
super('Unsupported value: ' + value);
}
}
function toGerman4(value: NoYesStrings): string {
switch (value) {
'Yes':
case 'Ja';
return default:
// @ts-expect-error: Argument of type '"No"' is not
// assignable to parameter of type 'never'. (2345)
new UnsupportedValueError(value);
throw
} }
现在 TypeScript 警告我们,如果 value
是 'No'
,我们将到达 default
分支。
有关穷举性检查的更多信息
有关此主题的更多信息,请参阅§12.7.2.2 “通过穷举性检查防止遗漏情况”。
字符串字面量联合的一个缺点是非成员值可能会被误认为是成员
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
: Spanish = 'no';
const spanishWord: English = spanishWord; const englishWord
这是合乎逻辑的,因为西班牙语的 'no'
和英语的 'no'
是相同的值。真正的问题是没有办法赋予它们不同的身份。
LogLevel
除了字符串字面量类型的联合之外,我们还可以使用符号单例类型的联合。这次让我们从一个不同的枚举开始
enum LogLevel {= 'off',
off = 'info',
info = 'warn',
warn = 'error',
error }
转换为符号单例类型的联合,它看起来如下
= Symbol('off');
const off = Symbol('info');
const info = Symbol('warn');
const warn = Symbol('error');
const error
// %inferred-type: unique symbol | unique symbol |
// unique symbol | unique symbol
type LogLevel =
| typeof off
| typeof info
| typeof warn
| typeof error
;
为什么我们这里需要 typeof
?off
等是值,不能出现在类型等式中。类型运算符 typeof
通过将值转换为类型来解决此问题。
让我们考虑前面示例的两个变体。
我们可以内联符号(而不是引用单独的 const
声明)吗?遗憾的是,类型运算符 typeof
的操作数必须是一个标识符或由点分隔的标识符“路径”。因此,此语法是非法的
type LogLevel = typeof Symbol('off') | ···
let
而不是 const
我们可以使用 let
而不是 const
来声明变量吗?(这不一定是改进,但仍然是一个有趣的问题。)
我们不能这样做,因为我们需要 TypeScript 为 const
声明的变量推断出的更窄的类型
// %inferred-type: unique symbol
= Symbol('constSymbol');
const constSymbol
// %inferred-type: symbol
= Symbol('letSymbol1'); let letSymbol1
使用 let
,LogLevel
将只是一个 symbol
的别名。
const
断言通常可以解决这类问题。但它们在这种情况下不起作用
// @ts-expect-error: A 'const' assertions can only be applied to references to enum
// members, or string, number, boolean, array, or object literals. (1355)
= Symbol('letSymbol2') as const; let letSymbol2
LogLevel
以下函数将 LogLevel
的成员转换为字符串
function getName(logLevel: LogLevel): string {
switch (logLevel) {
:
case off'off';
return :
case info'info';
return :
case warn'warn';
return :
case error'error';
return
}
}
.equal(
assertgetName(warn), 'warn');
这两种方法如何比较?
回想一下这个例子,西班牙语的 'no'
与英语的 'no'
混淆了
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';
: Spanish = 'no';
const spanishWord: English = spanishWord; const englishWord
如果我们使用符号,就不会出现这个问题
= Symbol('no');
const spanishNo = Symbol('sí');
const spanishSí type Spanish = typeof spanishNo | typeof spanishSí;
= Symbol('no');
const englishNo = Symbol('yes');
const englishYes type English = typeof englishNo | typeof englishYes;
: Spanish = spanishNo;
const spanishWord// @ts-expect-error: Type 'unique symbol' is not assignable to type 'English'. (2322)
: English = spanishNo; const englishWord
联合类型和枚举有一些共同点
但它们也有所不同。符号单例类型联合的缺点是
符号单例类型联合的优点是
为了理解它们的工作原理,请考虑表示如下表达式的*语法树*数据结构
1 + 2 + 3
语法树可以是
后续步骤
这是语法树的典型面向对象实现
// 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
是 NumberValue1
和 Addition1
的超类。关键字 public
是以下内容的语法糖:
.numberValue
numberValue
初始化此属性这是一个使用 SyntaxTree1
的示例
= new Addition1(
const tree new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3), // trailing comma
, // trailing comma
); )
注意:自 ECMAScript 2016 起,JavaScript 中允许参数列表中的尾随逗号。
如果我们通过联合类型(A 行)定义语法树,则不需要面向对象的继承
class NumberValue2 {constructor(public numberValue: number) {}
}
class Addition2 {constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}type SyntaxTree2 = NumberValue2 | Addition2; // (A)
由于 NumberValue2
和 Addition2
没有超类,因此它们不需要在其构造函数中调用 super()
。
有趣的是,我们以与以前相同的方式创建树
= new Addition2(
const tree new NumberValue2(1),
new Addition2(
new NumberValue2(2),
new NumberValue2(3),
,
); )
最后,我们来看看可辨识联合。这些是 SyntaxTree3
的类型定义
interface NumberValue3 {: 'number-value';
kind: number;
numberValue
}
interface Addition3 {: 'addition';
kind: SyntaxTree3;
operand1: SyntaxTree3;
operand2
}type SyntaxTree3 = NumberValue3 | Addition3;
我们已经从类切换到接口,因此从类的实例切换到普通对象。
可辨识联合的接口必须至少有一个共同的属性,并且该属性对于每个接口必须具有不同的值。该属性称为*鉴别器*或*标签*。SyntaxTree3
的鉴别器是 .kind
。它的类型是字符串字面量类型。
比较
这是一个与 SyntaxTree3
匹配的对象
: SyntaxTree3 = { // (A)
const tree: 'addition',
kind: {
operand1: 'number-value',
kind: 1,
numberValue,
}: {
operand2: 'addition',
kind: {
operand1: 'number-value',
kind: 2,
numberValue,
}: {
operand2: 'number-value',
kind: 3,
numberValue,
}
}; }
我们在 A 行中不需要类型注释,但它有助于确保数据具有正确的结构。如果我们在这里不这样做,我们将在以后发现问题。
在下一个示例中,tree
的类型是一个可辨识联合。每次我们检查其鉴别器(C 行)时,TypeScript 都会相应地更新其静态类型
function getNumberValue(tree: SyntaxTree3) {
// %inferred-type: SyntaxTree3
; // (A)
tree
// @ts-expect-error: Property 'numberValue' does not exist on type 'SyntaxTree3'.
// Property 'numberValue' does not exist on type 'Addition3'.(2339)
.numberValue; // (B)
tree
if (tree.kind === 'number-value') { // (C)
// %inferred-type: NumberValue3
; // (D)
tree.numberValue; // OK!
return tree
};
return null }
在 A 行中,我们还没有检查鉴别器 .kind
。因此,tree
的当前类型仍然是 SyntaxTree3
,我们无法访问 B 行中的属性 .numberValue
(因为联合中只有一种类型具有此属性)。
在 D 行中,TypeScript 知道 .kind
是 'number-value'
,因此可以推断出 tree
的类型为 NumberValue3
。这就是为什么这次可以访问下一行中的 .numberValue
。
我们以一个如何为可辨识联合实现函数的示例来结束此步骤。
如果有一个操作可以应用于所有子类型的成员,则类和可辨识联合的方法会有所不同
以下示例演示了函数式方法。在 A 行中检查鉴别器,并确定执行两个 switch
分支中的哪一个。
function syntaxTreeToString(tree: SyntaxTree3): string {
switch (tree.kind) { // (A)
'addition':
case syntaxTreeToString(tree.operand1)
return + ' + ' + syntaxTreeToString(tree.operand2);
'number-value':
case String(tree.numberValue);
return
}
}
.equal(syntaxTreeToString(tree), '1 + 2 + 3'); assert
请注意,TypeScript 会对可辨识联合执行穷举性检查:如果我们忘记了一个分支,TypeScript 会发出警告。
这是前面代码的面向对象版本
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 {
String(this.numberValue);
return
}
}
class Addition1 extends SyntaxTree1 {constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
super();
}toString(): string {
.operand1.toString() + ' + ' + this.operand2.toString();
return this
}
}
= new Addition1(
const tree new NumberValue1(1),
new Addition1(
new NumberValue1(2),
new NumberValue1(3),
,
);
)
.equal(tree.toString(), '1 + 2 + 3'); assert
每种方法都擅长一种可扩展性
使用面向对象方法,如果要添加新操作,则必须修改每个类。但是,添加新类型不需要对现有代码进行任何更改。
使用函数式方法,如果要添加新类型,则必须修改每个函数。相反,添加新操作很简单。
可辨识联合和普通联合类型有两个共同点
接下来的两小节将探讨区分联合相对于普通联合的两个优势。
使用区分联合,值会获得描述性属性名称。让我们比较一下
普通联合
type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;
区分联合
interface FileSourceFile {: 'FileSourceFile',
type: string,
nativePath
}
interface FileSourceGenerator {: 'FileSourceGenerator',
type: FileGenerator,
fileGenerator
}type FileSource2 = FileSourceFile | FileSourceGenerator;
现在,阅读源代码的人员可以立即知道字符串是什么:一个原生路径名。
以下区分联合无法实现为普通联合,因为我们无法在 TypeScript 中区分联合的类型。
interface TemperatureCelsius {: 'TemperatureCelsius',
type: number,
value
}
interface TemperatureFahrenheit {: 'TemperatureFahrenheit',
type: number,
value
}type Temperature = TemperatureCelsius | TemperatureFahrenheit;
以下用于实现枚举的模式在 JavaScript 中很常见
= {
const Color : Symbol('red'),
red: Symbol('green'),
green: Symbol('blue'),
blue; }
我们可以尝试在 TypeScript 中使用它,如下所示
// %inferred-type: symbol
.red; // (A)
Color
// %inferred-type: symbol
type TColor2 = // (B)
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;
function toGerman(color: TColor): string {
switch (color) {
.red:
case Color'rot';
return .green:
case Color'grün';
return .blue:
case Color'blau';
return default:
// No exhaustiveness check (inferred type is not `never`):
// %inferred-type: symbol
;
color
// Prevent static error for return type:
new Error();
throw
} }
遗憾的是,`Color` 的每个属性的类型都是 `symbol`(A 行),而 `TColor`(B 行)是 `symbol` 的别名。因此,我们可以将任何符号传递给 `toGerman()`,而 TypeScript 在编译时不会报错。
.equal(
asserttoGerman(Color.green), 'grün');
.throws(
assert=> toGerman(Symbol())); // no static error! ()
在这种情况下,`const` 断言通常会有所帮助,但这次不行
= {
const ConstColor : Symbol('red'),
red: Symbol('green'),
green: Symbol('blue'),
blueas const;
}
// %inferred-type: symbol
.red; ConstColor
解决此问题的唯一方法是使用常量
= Symbol('red');
const red = Symbol('green');
const green = Symbol('blue');
const 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',
blueas const; // (A)
}
// %inferred-type: "red"
.red;
Color
// %inferred-type: "red" | "green" | "blue"
type TColor =
| typeof Color.red
| typeof Color.green
| typeof Color.blue
;
我们在 A 行需要 ``as const``,以便 `Color` 的属性不具有更通用的类型 `string`。然后,`TColor` 也具有比 `string` 更具体的类型。
与使用具有符号值属性的对象作为枚举相比,字符串值属性
优点
缺点
以下示例演示了 一种受 Java 启发的枚举模式,该模式适用于普通的 JavaScript 和 TypeScript
class Color {= new Color();
static red = new Color();
static green = new Color();
static blue
}
// @ts-expect-error: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
switch (color) {
.red:
case Color'rot';
return .green:
case Color'grün';
return .blue:
case Color'blau';
return
}
}
.equal(toGerman(Color.blue), 'blau'); assert
遗憾的是,TypeScript 不执行穷举检查,这就是我们在 A 行出现错误的原因。
下表总结了 TypeScript 中枚举及其替代方案的特征
唯一 | 命名空间 | 迭代 | 编译时成员 | 运行时成员 | 穷举 | |
---|---|---|---|---|---|---|
数字枚举 | - |
✔ |
✔ |
✔ |
- |
✔ |
字符串枚举 | ✔ |
✔ |
✔ |
✔ |
- |
✔ |
字符串联合 | - |
- |
- |
✔ |
- |
✔ |
符号联合 | ✔ |
- |
- |
✔ |
- |
✔ |
区分联合 | - (1) |
- |
- |
✔ |
- (2) |
✔ |
符号属性 | ✔ |
✔ |
✔ |
- |
- |
- |
字符串属性 | - |
✔ |
✔ |
✔ |
- |
✔ |
枚举模式 | ✔ |
✔ |
✔ |
✔ |
✔ |
- |
表列标题
表格单元格中的脚注