深入理解 TypeScript
请支持本书:购买捐赠
(广告,请勿屏蔽。)

13 TypeScript 中枚举的替代方案



上一章探讨了 TypeScript 枚举的工作原理。在本章中,我们将介绍枚举的替代方案。

13.1 单例值联合类型

枚举将成员名称映射到成员值。如果我们不需要或不想进行间接映射,可以使用所谓的*原始字面量类型*的联合——每个值对应一个类型。在深入探讨之前,我们需要了解原始字面量类型。

13.1.1 原始字面量类型

快速回顾:我们可以将类型视为值的集合。

*单例类型*是只有一个元素的类型。原始字面量类型是单例类型

type UndefinedLiteralType = undefined;
type NullLiteralType = null;

type BooleanLiteralType = true;
type NumericLiteralType = 123;
type BigIntLiteralType = 123n; // --target must be ES2020+
type StringLiteralType = 'abc';

UndefinedLiteralType 是只有一个元素 undefined 的类型,等等。

重要的是要注意这里涉及的两个语言级别(我们已经在本书前面遇到过这些级别)。请考虑以下变量声明

const abc: 'abc' = 'abc';

原始字面量类型的两个用例是

继续阅读以获取有关第二个用例的更多信息。

13.1.2 字符串字面量类型联合

我们将从一个枚举开始,并将其转换为字符串字面量类型的联合。

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');

NoYesStringsNoYesEnum 的联合类型版本

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');

类型 NoYesStrings 是字符串字面量类型 'No''Yes' 的联合。联合类型运算符 | 与集合论中的并集运算符 相关。

13.1.2.1 可以检查字符串字面量类型联合的穷举性

以下代码演示了穷举性检查适用于字符串字面量类型的联合

// @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';
  }
}

我们忘记了 'No' 的情况,TypeScript 警告我们该函数可能会返回非字符串值。

我们也可以更明确地检查穷举性

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);
  }
}

现在 TypeScript 警告我们,如果 value'No',我们将到达 default 分支。

  有关穷举性检查的更多信息

有关此主题的更多信息,请参阅§12.7.2.2 “通过穷举性检查防止遗漏情况”

13.1.2.2 缺点:字符串字面量联合的类型安全性较低

字符串字面量联合的一个缺点是非成员值可能会被误认为是成员

type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;

这是合乎逻辑的,因为西班牙语的 'no' 和英语的 'no' 是相同的值。真正的问题是没有办法赋予它们不同的身份。

13.1.3 符号单例类型联合

13.1.3.1 示例:LogLevel

除了字符串字面量类型的联合之外,我们还可以使用符号单例类型的联合。这次让我们从一个不同的枚举开始

enum LogLevel {
  off = 'off',
  info = 'info',
  warn = 'warn',
  error = 'error',
}

转换为符号单例类型的联合,它看起来如下

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
;

为什么我们这里需要 typeofoff 等是值,不能出现在类型等式中。类型运算符 typeof 通过将值转换为类型来解决此问题。

让我们考虑前面示例的两个变体。

13.1.3.2 变体 #1:内联符号

我们可以内联符号(而不是引用单独的 const 声明)吗?遗憾的是,类型运算符 typeof 的操作数必须是一个标识符或由点分隔的标识符“路径”。因此,此语法是非法的

type LogLevel = typeof Symbol('off') | ···
13.1.3.3 变体 #2:使用 let 而不是 const

我们可以使用 let 而不是 const 来声明变量吗?(这不一定是改进,但仍然是一个有趣的问题。)

我们不能这样做,因为我们需要 TypeScript 为 const 声明的变量推断出的更窄的类型

// %inferred-type: unique symbol
const constSymbol = Symbol('constSymbol');

// %inferred-type: symbol
let letSymbol1 = Symbol('letSymbol1');

使用 letLogLevel 将只是一个 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)
let letSymbol2 = Symbol('letSymbol2') as const;
13.1.3.4 在函数中使用 LogLevel

以下函数将 LogLevel 的成员转换为字符串

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');
13.1.3.5 符号单例类型联合与字符串字面量类型联合

这两种方法如何比较?

回想一下这个例子,西班牙语的 'no' 与英语的 'no' 混淆了

type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;

如果我们使用符号,就不会出现这个问题

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;

13.1.4 本节结论:联合类型与枚举

联合类型和枚举有一些共同点

但它们也有所不同。符号单例类型联合的缺点是

符号单例类型联合的优点是

13.2 可辨识联合

可辨识联合 与函数式编程语言中的代数数据类型有关。

为了理解它们的工作原理,请考虑表示如下表达式的*语法树*数据结构

1 + 2 + 3

语法树可以是

后续步骤

  1. 我们将首先为语法树创建一个面向对象的类层次结构。
  2. 然后,我们将其转换为更具函数式风格的结构。
  3. 最后,我们将得到一个可辨识联合。

13.2.1 步骤 1:语法树作为类层次结构

这是语法树的典型面向对象实现

// 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();
  }
}

SyntaxTree1NumberValue1Addition1 的超类。关键字 public 是以下内容的语法糖:

这是一个使用 SyntaxTree1 的示例

const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3), // trailing comma
  ), // trailing comma
);

注意:自 ECMAScript 2016 起,JavaScript 中允许参数列表中的尾随逗号

13.2.2 步骤 2:语法树作为类的联合类型

如果我们通过联合类型(A 行)定义语法树,则不需要面向对象的继承

class NumberValue2 {
  constructor(public numberValue: number) {}
}
class Addition2 {
  constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}
type SyntaxTree2 = NumberValue2 | Addition2; // (A)

由于 NumberValue2Addition2 没有超类,因此它们不需要在其构造函数中调用 super()

有趣的是,我们以与以前相同的方式创建树

const tree = new Addition2(
  new NumberValue2(1),
  new Addition2(
    new NumberValue2(2),
    new NumberValue2(3),
  ),
);

13.2.3 步骤 3:语法树作为可辨识联合

最后,我们来看看可辨识联合。这些是 SyntaxTree3 的类型定义

interface NumberValue3 {
  kind: 'number-value';
  numberValue: number;
}
interface Addition3 {
  kind: 'addition';
  operand1: SyntaxTree3;
  operand2: SyntaxTree3;
}
type SyntaxTree3 = NumberValue3 | Addition3;

我们已经从类切换到接口,因此从类的实例切换到普通对象。

可辨识联合的接口必须至少有一个共同的属性,并且该属性对于每个接口必须具有不同的值。该属性称为*鉴别器*或*标签*。SyntaxTree3 的鉴别器是 .kind。它的类型是字符串字面量类型

比较

这是一个与 SyntaxTree3 匹配的对象

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,
    },
  }
};

我们在 A 行中不需要类型注释,但它有助于确保数据具有正确的结构。如果我们在这里不这样做,我们将在以后发现问题。

在下一个示例中,tree 的类型是一个可辨识联合。每次我们检查其鉴别器(C 行)时,TypeScript 都会相应地更新其静态类型

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;
}

在 A 行中,我们还没有检查鉴别器 .kind。因此,tree 的当前类型仍然是 SyntaxTree3,我们无法访问 B 行中的属性 .numberValue(因为联合中只有一种类型具有此属性)。

在 D 行中,TypeScript 知道 .kind'number-value',因此可以推断出 tree 的类型为 NumberValue3。这就是为什么这次可以访问下一行中的 .numberValue

13.2.3.1 为可辨识联合实现函数

我们以一个如何为可辨识联合实现函数的示例来结束此步骤。

如果有一个操作可以应用于所有子类型的成员,则类和可辨识联合的方法会有所不同

以下示例演示了函数式方法。在 A 行中检查鉴别器,并确定执行两个 switch 分支中的哪一个。

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');

请注意,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 {
    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');
13.2.3.2 可扩展性:面向对象方法与函数式方法

每种方法都擅长一种可扩展性

13.2.4 可辨识联合与普通联合类型

可辨识联合和普通联合类型有两个共同点

接下来的两小节将探讨区分联合相对于普通联合的两个优势。

13.2.4.1 优势:描述性属性名称

使用区分联合,值会获得描述性属性名称。让我们比较一下

普通联合

type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;

区分联合

interface FileSourceFile {
  type: 'FileSourceFile',
  nativePath: string,
}
interface FileSourceGenerator {
  type: 'FileSourceGenerator',
  fileGenerator: FileGenerator,
}
type FileSource2 = FileSourceFile | FileSourceGenerator;

现在,阅读源代码的人员可以立即知道字符串是什么:一个原生路径名。

13.2.4.2 优势:当各部分无法区分时,我们也可以使用它

以下区分联合无法实现为普通联合,因为我们无法在 TypeScript 中区分联合的类型。

interface TemperatureCelsius {
  type: 'TemperatureCelsius',
  value: number,
}
interface TemperatureFahrenheit {
  type: 'TemperatureFahrenheit',
  value: number,
}
type Temperature = TemperatureCelsius | TemperatureFahrenheit;

13.3 对象字面量作为枚举

以下用于实现枚举的模式在 JavaScript 中很常见

const Color = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
};

我们可以尝试在 TypeScript 中使用它,如下所示

// %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();
  }
}

遗憾的是,`Color` 的每个属性的类型都是 `symbol`(A 行),而 `TColor`(B 行)是 `symbol` 的别名。因此,我们可以将任何符号传递给 `toGerman()`,而 TypeScript 在编译时不会报错。

assert.equal(
  toGerman(Color.green), 'grün');
assert.throws(
  () => toGerman(Symbol())); // no static error!

在这种情况下,`const` 断言通常会有所帮助,但这次不行

const ConstColor = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
} as const;

// %inferred-type: symbol
ConstColor.red;

解决此问题的唯一方法是使用常量

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;

13.3.1 具有字符串值属性的对象字面量

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
;

我们在 A 行需要 ``as const``,以便 `Color` 的属性不具有更通用的类型 `string`。然后,`TColor` 也具有比 `string` 更具体的类型。

与使用具有符号值属性的对象作为枚举相比,字符串值属性

13.3.2 使用对象字面量作为枚举的优点和缺点

优点

缺点

13.4 枚举模式

以下示例演示了 一种受 Java 启发的枚举模式,该模式适用于普通的 JavaScript 和 TypeScript

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');

遗憾的是,TypeScript 不执行穷举检查,这就是我们在 A 行出现错误的原因。

13.5 枚举和枚举替代方案的摘要

下表总结了 TypeScript 中枚举及其替代方案的特征

唯一 命名空间 迭代 编译时成员 运行时成员 穷举
数字枚举 - -
字符串枚举 -
字符串联合 - - - -
符号联合 - - -
区分联合 - (1) - - - (2)
符号属性 - - -
字符串属性 - -
枚举模式 -

表列标题

表格单元格中的脚注

  1. 区分联合实际上并不是唯一的,但是将值误认为是联合成员的可能性相对较小(特别是如果我们对鉴别器属性使用唯一名称)。
  2. 如果鉴别器属性具有足够唯一的名称,则可以使用它来检查成员资格。

13.6 致谢