Tackling TypeScript
請支持這本書:購買捐款
(廣告,請不要封鎖。)

12 TypeScript 列舉:它們如何運作?它們可以做什麼?



本章節將回答以下兩個問題

下一章節 中,我們將探討列舉的替代方案。

12.1 基礎

boolean 是一種具有有限值的類型:falsetrue。透過列舉,TypeScript 讓我們自行定義類似的類型。

12.1.1 數字列舉

這是一個數字列舉

enum NoYes {
  No = 0,
  Yes = 1, // trailing comma
}

assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);

說明

我們可以使用成員,就像使用文字一樣,例如 true123'abc',例如

function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}
assert.equal(toGerman(NoYes.No), 'Nein');
assert.equal(toGerman(NoYes.Yes), 'Ja');

12.1.2 字串列舉

我們也可以使用字串作為列舉成員值,而不是數字

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

assert.equal(NoYes.No, 'No');
assert.equal(NoYes.Yes, 'Yes');

12.1.3 異質列舉

最後一種列舉稱為異質。異質列舉的成員值是數字和字串的混合

enum Enum {
  One = 'One',
  Two = 'Two',
  Three = 3,
  Four = 4,
}
assert.deepEqual(
  [Enum.One, Enum.Two, Enum.Three, Enum.Four],
  ['One', 'Two', 3, 4]
);

異質列舉不常使用,因為它們的應用很少。

遺憾的是,TypeScript 僅支援數字和字串作為列舉成員值。其他值,例如符號,是不允許的。

12.1.4 省略初始化器

我們可以在兩種情況下省略初始化器

這是一個沒有任何初始化器的數字列舉

enum NoYes {
  No,
  Yes,
}
assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);

這是一個異質列舉,其中省略了一些初始化器

enum Enum {
  A,
  B,
  C = 'C',
  D = 'D',
  E = 8, // (A)
  F,
}
assert.deepEqual(
  [Enum.A, Enum.B, Enum.C, Enum.D, Enum.E, Enum.F],
  [0, 1, 'C', 'D', 8, 9]
);

請注意,我們無法在 A 行省略初始化器,因為前一個成員的值不是數字。

12.1.5 列舉成員名稱的大小寫

有許多命名常數(在列舉或其他地方)的先例

12.1.6 列舉成員名稱的引號

類似 JavaScript 物件,我們可以引用列舉成員的名稱

enum HttpRequestField {
  'Accept',
  'Accept-Charset',
  'Accept-Datetime',
  'Accept-Encoding',
  'Accept-Language',
}
assert.equal(HttpRequestField['Accept-Charset'], 1);

無法計算列舉成員的名稱。物件文字透過方括號支援計算屬性金鑰。

12.2 指定列舉成員值(進階)

TypeScript 根據初始化方式區分三種類型的列舉成員

到目前為止,我們只使用文字成員。

在先前的清單中,較早提及的成員較不靈活,但支援更多功能。請繼續閱讀以取得更多資訊。

12.2.1 文字列舉成員

列舉成員為文字,如果其值已指定

如果列舉只有文字成員,我們可以使用這些成員作為類型(類似於數字文字可用作類型的方式)

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
function func(x: NoYes.No) { // (A)
  return x;
}

func(NoYes.No); // OK

// @ts-expect-error: Argument of type '"No"' is not assignable to
// parameter of type 'NoYes.No'.
func('No');

// @ts-expect-error: Argument of type 'NoYes.Yes' is not assignable to
// parameter of type 'NoYes.No'.
func(NoYes.Yes);

A 行中的 NoYes.No列舉成員類型

此外,文字列舉支援窮盡性檢查(我們稍後會探討)。

12.2.2 常數列舉成員

列舉成員為常數,如果其值可在編譯時計算。因此,我們可以隱含指定其值(也就是讓 TypeScript 為我們指定)。或者,我們可以明確指定,並且只允許使用下列語法

這是成員全部為常數的列舉範例(稍後我們會看到如何使用該列舉)

enum Perm {
  UserRead     = 1 << 8, // bit 8
  UserWrite    = 1 << 7,
  UserExecute  = 1 << 6,
  GroupRead    = 1 << 5,
  GroupWrite   = 1 << 4,
  GroupExecute = 1 << 3,
  AllRead      = 1 << 2,
  AllWrite     = 1 << 1,
  AllExecute   = 1 << 0,
}

一般而言,常數成員無法用作類型。不過,仍會執行窮盡性檢查。

12.2.3 計算列舉成員

計算列舉成員的值可透過任意表達式指定。例如

enum NoYesNum {
  No = 123,
  Yes = Math.random(), // OK
}

這是數字列舉。基於字串的列舉和異質列舉的限制較多。例如,我們無法使用方法呼叫來指定成員值

enum NoYesStr {
  No = 'No',
  // @ts-expect-error: Computed values are not permitted in
  // an enum with string valued members.
  Yes = ['Y', 'e', 's'].join(''),
}

TypeScript 對於計算後的列舉成員不會執行窮舉性檢查。

12.3 數值列舉的缺點

12.3.1 缺點:記錄

記錄數值列舉的成員時,我們只會看到數字

enum NoYes { No, Yes }

console.log(NoYes.No);
console.log(NoYes.Yes);

// Output:
// 0
// 1

12.3.2 缺點:鬆散類型檢查

將列舉用作類型時,靜態允許的值不只是列舉成員的值,而是接受任何數字

enum NoYes { No, Yes }
function func(noYes: NoYes) {}
func(33); // no error!

為什麼沒有更嚴格的靜態檢查?Daniel Rosenwasser 解釋

這種行為是由於位元運算而產生的。有時 SomeFlag.Foo | SomeFlag.Bar 被用來產生另一個 SomeFlag。但最後你會得到 number,而你不想將其轉換回 SomeFlag

我想如果我們重新設計 TypeScript 並保留列舉,我們會為位元標記建立一個獨立的建構。

稍後會更詳細地說明列舉如何用於位元模式。

12.3.3 建議:優先使用基於字串的列舉

我的建議是優先使用基於字串的列舉(為了簡潔起見,本章並非總是遵循此建議)

enum NoYes { No='No', Yes='Yes' }

一方面,記錄輸出對人類來說更有用

console.log(NoYes.No);
console.log(NoYes.Yes);

// Output:
// 'No'
// 'Yes'

另一方面,我們可以獲得更嚴格的類型檢查

function func(noYes: NoYes) {}

// @ts-expect-error: Argument of type '"abc"' is not assignable
// to parameter of type 'NoYes'.
func('abc');

// @ts-expect-error: Argument of type '"Yes"' is not assignable
// to parameter of type 'NoYes'.
func('Yes'); // (A)

甚至連等於成員值的字串都不允許(A 行)。

12.4 列舉的用例

12.4.1 用例:位元模式

Node.js 檔案系統模組 中,幾個函式都有 mode 參數。它透過數字編碼來指定檔案權限,這是 Unix 的遺留物

這表示權限可以用 9 個位元來表示(3 個類別,每個類別有 3 個權限)

使用者 群組 全部
權限 r, w, x r, w, x r, w, x
位元 8, 7, 6 5, 4, 3 2, 1, 0

Node.js 沒有這麼做,但我們可以使用列舉來處理這些旗標

enum Perm {
  UserRead     = 1 << 8, // bit 8
  UserWrite    = 1 << 7,
  UserExecute  = 1 << 6,
  GroupRead    = 1 << 5,
  GroupWrite   = 1 << 4,
  GroupExecute = 1 << 3,
  AllRead      = 1 << 2,
  AllWrite     = 1 << 1,
  AllExecute   = 1 << 0,
}

位元模式透過 按位元或 結合

// User can change, read and execute.
// Everyone else can only read and execute.
assert.equal(
  Perm.UserRead | Perm.UserWrite | Perm.UserExecute |
  Perm.GroupRead | Perm.GroupExecute |
  Perm.AllRead | Perm.AllExecute,
  0o755);

// User can read and write.
// Group members can read.
// Everyone can’t access at all.
assert.equal(
  Perm.UserRead | Perm.UserWrite | Perm.GroupRead,
  0o640);
12.4.1.1 位元模式的替代方案

位元模式背後的主要概念是有一組旗標,且可以選擇這些旗標的任何子集。

因此,使用真實的集合來選擇子集是執行相同任務的更直接方式

enum Perm {
  UserRead = 'UserRead',
  UserWrite = 'UserWrite',
  UserExecute = 'UserExecute',
  GroupRead = 'GroupRead',
  GroupWrite = 'GroupWrite',
  GroupExecute = 'GroupExecute',
  AllRead = 'AllRead',
  AllWrite = 'AllWrite',
  AllExecute = 'AllExecute',
}
function writeFileSync(
  thePath: string, permissions: Set<Perm>, content: string) {
  // ···
}
writeFileSync(
  '/tmp/hello.txt',
  new Set([Perm.UserRead, Perm.UserWrite, Perm.GroupRead]),
  'Hello!');

12.4.2 使用案例:多個常數

有時候,我們有屬於同一組的常數

const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');

這是列舉的良好使用案例

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

列舉的一個好處是常數名稱會分組並巢狀在名稱空間 LogLevel 內。

另一個好處是我們會自動取得它們的類型 LogLevel。如果我們要為常數取得此類型的值,我們需要多做一些工作

type LogLevel =
  | typeof off
  | typeof info
  | typeof warn
  | typeof error
;

有關此方法的更多資訊,請參閱 §13.1.3「符號單例類型的聯集」

12.4.3 使用案例:比布林值更具自述性

當布林值用於表示替代方案時,列舉通常更具自述性。

12.4.3.1 布林值範例:已排序與未排序清單

例如,若要表示清單是否已排序,我們可以使用布林值

class List1 {
  isOrdered: boolean;
  // ···
}

然而,列舉更具自述性,且具有額外的優點,如果我們需要,我們可以在稍後新增更多替代方案。

enum ListKind { ordered, unordered }
class List2 {
  listKind: ListKind;
  // ···
}
12.4.3.2 布林值範例:錯誤處理模式

類似地,我們可以透過布林值指定如何處理錯誤

function convertToHtml1(markdown: string, throwOnError: boolean) {
  // ···
}

或者,我們可以透過列舉值這麼做

enum ErrorHandling {
  throwOnError = 'throwOnError',
  showErrorsInContent = 'showErrorsInContent',
}
function convertToHtml2(markdown: string, errorHandling: ErrorHandling) {
  // ···
}

12.4.4 使用案例:更好的字串常數

考慮下列建立正規表達式的函式。

const GLOBAL = 'g';
const NOT_GLOBAL = '';
type Globalness = typeof GLOBAL | typeof NOT_GLOBAL;

function createRegExp(source: string,
  globalness: Globalness = NOT_GLOBAL) {
    return new RegExp(source, 'u' + globalness);
  }

assert.deepEqual(
  createRegExp('abc', GLOBAL),
  /abc/ug);

assert.deepEqual(
  createRegExp('abc', 'g'), // OK
  /abc/ug);

我們可以使用列舉,而不是字串常數

enum Globalness {
  Global = 'g',
  notGlobal = '',
}

function createRegExp(source: string, globalness = Globalness.notGlobal) {
  return new RegExp(source, 'u' + globalness);
}

assert.deepEqual(
  createRegExp('abc', Globalness.Global),
  /abc/ug);

assert.deepEqual(
  // @ts-expect-error: Argument of type '"g"' is not assignable to parameter of type 'Globalness | undefined'. (2345)
  createRegExp('abc', 'g'), // error
  /abc/ug);

此方法有什麼好處?

12.5 執行時期的列舉

TypeScript 編譯列舉至 JavaScript 物件。舉例來說,請看下列列舉

enum NoYes {
  No,
  Yes,
}

TypeScript 編譯此列舉至

var NoYes;
(function (NoYes) {
  NoYes[NoYes["No"] = 0] = "No";
  NoYes[NoYes["Yes"] = 1] = "Yes";
})(NoYes || (NoYes = {}));

在此程式碼中,會進行下列指定

NoYes["No"] = 0;
NoYes["Yes"] = 1;

NoYes[0] = "No";
NoYes[1] = "Yes";

共有兩組指派

12.5.1 反向對應

給定一個數字列舉

enum NoYes {
  No,
  Yes,
}

一般對應會從成員名稱到成員值

// Static (= fixed) lookup:
assert.equal(NoYes.Yes, 1);

// Dynamic lookup:
assert.equal(NoYes['Yes'], 1);

數字列舉也支援從成員值到成員名稱的反向對應

assert.equal(NoYes[1], 'Yes');

反向對應的一個用例是列印列舉成員的名稱

function getQualifiedName(value: NoYes) {
  return 'NoYes.' + NoYes[value];
}
assert.equal(
  getQualifiedName(NoYes.Yes), 'NoYes.Yes');

12.5.2 執行時期的字串型別列舉

字串型別列舉在執行時期有較為簡單的表示方式。

考慮以下列舉。

enum NoYes {
  No = 'NO!',
  Yes = 'YES!',
}

它會編譯成這個 JavaScript 程式碼

var NoYes;
(function (NoYes) {
    NoYes["No"] = "NO!";
    NoYes["Yes"] = "YES!";
})(NoYes || (NoYes = {}));

TypeScript 不支援字串型別列舉的反向對應。

12.6 const 列舉

如果列舉加上關鍵字 const 前綴,它在執行時期不會有表示方式。相反地,其成員的值會直接使用。

12.6.1 編譯非 const 列舉

為了觀察這個效應,我們先來檢視以下非 const 列舉

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}

TypeScript 會將這個程式碼編譯成

"use strict";
var NoYes;
(function (NoYes) {
  NoYes["No"] = "No";
  NoYes["Yes"] = "Yes";
})(NoYes || (NoYes = {}));

function toGerman(value) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}

12.6.2 編譯 const 列舉

這和先前的程式碼相同,但現在列舉是 const

const enum NoYes {
  No,
  Yes,
}
function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}

現在列舉作為建構的表示方式消失了,只剩下其成員的值

function toGerman(value) {
  switch (value) {
    case "No" /* No */:
      return 'Nein';
    case "Yes" /* Yes */:
      return 'Ja';
  }
}

12.7 編譯時期的列舉

12.7.1 列舉是物件

TypeScript 將 (非 const) 列舉當成物件處理

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
function func(obj: { No: string }) {
  return obj.No;
}
assert.equal(
  func(NoYes), // allowed statically!
  'No');

12.7.2 文字列舉的安全檢查

當我們接受列舉成員值時,我們通常希望確保

請繼續閱讀以取得更多資訊。我們將使用以下列舉

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}
12.7.2.1 防範不合法值

在以下程式碼中,我們針對不合法值採取兩項措施

function toGerman1(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
    default:
      throw new TypeError('Unsupported value: ' + JSON.stringify(value));
  }
}

assert.throws(
  // @ts-expect-error: Argument of type '"Maybe"' is not assignable to
  // parameter of type 'NoYes'.
  () => toGerman1('Maybe'),
  /^TypeError: Unsupported value: "Maybe"$/);

措施如下

12.7.2.2 透過窮舉檢查防範忘記案例

我們可以採取另一項措施。以下程式碼執行窮舉檢查:如果我們忘記考慮所有列舉成員,TypeScript 會警告我們。

class UnsupportedValueError extends Error {
  constructor(value: never) {
    super('Unsupported value: ' + value);
  }
}

function toGerman2(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
    default:
      throw new UnsupportedValueError(value);
  }
}

窮舉檢查如何運作?對於每個案例,TypeScript 會推斷 value 的型別

function toGerman2b(value: NoYes) {
  switch (value) {
    case NoYes.No:
      // %inferred-type: NoYes.No
      value;
      return 'Nein';
    case NoYes.Yes:
      // %inferred-type: NoYes.Yes
      value;
      return 'Ja';
    default:
      // %inferred-type: never
      value;
      throw new UnsupportedValueError(value);
  }
}

在預設案例中,TypeScript 會為 value 推斷型別 never,因為我們永遠不會到達那裡。但是,如果我們新增成員 .MaybeNoYes,那麼 value 的推斷型別就是 NoYes.Maybe。而那個型別在靜態上與 new UnsupportedValueError() 參數的型別 never 不相容。這就是為什麼我們在編譯時期會收到以下錯誤訊息

Argument of type 'NoYes.Maybe' is not assignable to parameter of type 'never'.

很方便的是,這種窮舉檢查也可以用在 if 陳述式

function toGerman3(value: NoYes) {
  if (value === NoYes.No) {
    return 'Nein';
  } else if (value === NoYes.Yes) {
    return 'Ja';
  } else {
    throw new UnsupportedValueError(value);
  }
}
12.7.2.3 檢查窮舉的另一種方式

或者,如果我們指定回傳類型,我們也可以得到一個詳盡的檢查

function toGerman4(value: NoYes): string {
  switch (value) {
    case NoYes.No:
      const x: NoYes.No = value;
      return 'Nein';
    case NoYes.Yes:
      const y: NoYes.Yes = value;
      return 'Ja';
  }
}

如果我們新增一個成員到 NoYes,那麼 TypeScript 會抱怨 toGerman4() 可能會回傳 undefined

這種方法的缺點

12.7.3 keyof 和列舉

我們可以使用 keyof 類型運算子來建立元素為列舉成員的鍵的類型。當我們這樣做時,我們需要將 keyoftypeof 結合

enum HttpRequestKeyEnum {
  'Accept',
  'Accept-Charset',
  'Accept-Datetime',
  'Accept-Encoding',
  'Accept-Language',
}
// %inferred-type: "Accept" | "Accept-Charset" | "Accept-Datetime" |
// "Accept-Encoding" | "Accept-Language"
type HttpRequestKey = keyof typeof HttpRequestKeyEnum;

function getRequestHeaderValue(request: Request, key: HttpRequestKey) {
  // ···
}
12.7.3.1 在沒有 typeof 的情況下使用 keyof

如果我們在沒有 typeof 的情況下使用 keyof,我們會得到一個不同且較不實用的類型

// %inferred-type: "toString" | "toFixed" | "toExponential" |
// "toPrecision" | "valueOf" | "toLocaleString"
type Keys = keyof HttpRequestKeyEnum;

keyof HttpRequestKeyEnumkeyof number 相同。

12.8 致謝