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

現在,如果 value'No',TypeScript 會警告我們會執行 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 致謝