一文讓你徹底掌握 TS 枚舉

創建了一個「重學TypeScript」的微信群,想加群的小夥伴,加我微信 「semlinker」,備註重學TS。

一、基礎知識

在 JavaScript 中布爾類型的變數含有有限範圍的值,即 truefalse。而在 TypeScript 中使用枚舉,你也可以自定義相似的類型。

1.1 數字枚舉

這是一個枚舉的簡單示例:

enum NoYes {    No,    Yes,  }

NoYes 被稱為枚舉 NoYes 的成員。與對象字面量一樣,尾隨逗號是被允許的。對於 NoYes 枚舉我們能夠輕易的訪問它的成員,比如:

function toChinese(value: NoYes) {    switch (value) {      case NoYes.No:        return '否';      case NoYes.Yes:        return '是';    }  }    assert.equal(toChinese(NoYes.No), '否');  assert.equal(toChinese(NoYes.Yes), '是');

1.1.1 枚舉成員值

每個枚舉成員都有一個 name 和一個 value。數字枚舉成員值的默認類型是 number 類型。也就是說,每個成員的值都是一個數字:

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

除了讓 TypeScript 為我們指定枚舉成員的值之外,我們還可以手動賦值:

enum NoYes {    No = 0,    Yes = 1,  }

這種通過等號的顯式賦值稱為 initializer。如果枚舉中某個成員的值使用顯式方式賦值,但後續成員未顯示賦值, TypeScript 會基於當前成員的值加 1 作為後續成員的值,比如以下 Enum 枚舉中的成員 C:

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

1.2 枚舉成員名稱的轉換

常量的命名有幾種約定:

  • 傳統上,JavaScript 使用全大寫的名稱,這是它從 Java 和 C 繼承的約定: Number.MAX_VALUE
  • 眾所周知的 Symbol 用駝峰式表示,並以小寫字母開頭,因為它們與屬性名稱相關: Symbol.asyncIterator
  • TypeScript 手冊使用以大寫字母開頭的駝峰式名稱。這是標準的 TypeScript 風格,我們將其用於 NoYes 枚舉。

1.3 引用枚舉成員名稱

與 JavaScript 對象類似,我們可以使用方括弧來引用包含非法字元的枚舉成員:

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

1.4 基於字元串的枚舉

除了數字枚舉,我們還可以使用字元串作為枚舉成員值:

enum NoYes {    No = 'No',    Yes = 'Yes',  }    assert.equal(NoYes.No, 'No');  assert.equal(NoYes.Yes, 'Yes');

對於純字元串枚舉,我們不能省略任何初始化程式。

1.5 異構枚舉

最後一種枚舉稱為異構枚舉。異構枚舉的成員值是數字和字元串的混合:

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

請注意,前面提到的規則也適用於此:如果先前的成員值為數字,則我們能省略初始化程式。異構枚舉由於其應用較少而很少使用。

目前 TypeScript 只支援將數字和字元串作為枚舉成員值。不允許使用其他值,比如 symbols。

二、指定枚舉成員值

TypeScript 區分了三種指定枚舉成員值的方式:

  • 使用字面量進行初始化:
    • 隱式指定;
    • 通過數字字面量或字元串字面量。
  • 常量枚舉成員通過可在編譯時計算其結果的表達式初始化。
  • 計算的枚舉成員可通過任意表達式初始化。

2.1 字面量枚舉成員

如果枚舉只有字面量成員,我們可以將這些成員用作類型(類似於數字字面量可以用作類型的方式):

enum NoYes {    No = 'No',    Yes = 'Yes',  }    function func(x: NoYes.No) {    return x;  }    func(NoYes.No); // OK    //@ts-ignore: Argument of type '"No"' is not assignable to  //            parameter of type 'NoYes.No'.  func('No');    //@ts-ignore: Argument of type 'NoYes.Yes' is not assignable to  //            parameter of type 'NoYes.No'.  func(NoYes.Yes);

此外,字面量枚舉支援完整性檢查(我們將在後面進行介紹)。

TypeScript 2.6 支援在 .ts 文件中通過在報錯一行上方使用 // @ts-ignore 來忽略錯誤。 // @ts-ignore 注釋會忽略下一行中產生的所有錯誤。建議實踐中在 @ts-ignore之後添加相關提示,解釋忽略了什麼錯誤。 請注意,這個注釋僅會隱藏報錯,並且我們建議你少使用這一注釋。

2.2 const 枚舉成員

如果可以在編譯時計算枚舉成員的值,則該枚舉成員是常量。因此,我們可以隱式指定其值(即,讓 TypeScript 為我們指定它的值)。或者我們可以顯式指定它的值,並且僅允許使用以下語法:

  • 數字字面量或字元串字面量
  • 對先前定義的常量枚舉成員的引用
  • 括弧
  • 一元運算符 +-~
  • 二進位運算符 +-*/%<<>>>>>&|^

以下是一個成員都是常量的枚舉示例:

enum Perm {    UserRead     = 1 << 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,  }

如果枚舉僅含有常量成員,則不能再將成員用作類型。但是我們仍然可以進行完整性檢查。

2.3 計算枚舉成員

可以通過任意表達式設置枚舉成員的值。例如:

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

這是一個數字枚舉。字元串枚舉和異構枚舉會有更多的限制。例如,我們不能調用某些方法來設定枚舉成員的值:

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

三、數字枚舉的缺點

3.1 缺點:日誌輸出

在輸出數字枚舉的成員時,我們只會看到數字:

enum NoYes { No, Yes }    console.log(NoYes.No);  console.log(NoYes.Yes);    // Output:  // 0  // 1

3.2 缺點:鬆散型檢查

將枚舉用作類型時,允許的值不只是枚舉成員的值 – 可以接受任何數字:

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

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

該行為是由按位運算引起的。有時 SomeFlag.Foo | SomeFlag.Bar 打算產生另一種 SomeFlag。相反,您最終得到了 number,並且你不想回退到 SomeFlag。 我認為,如果我們再次運行 TypeScript 之後仍然有枚舉,那麼我們將為位標誌建立一個單獨的構造。

3.3 建議:使用字元串枚舉

我的建議是使用字元串枚舉:

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

一方面,日誌輸出對人類更友好:

console.log(NoYes.No);  console.log(NoYes.Yes);    // Output:  // 'No'  // 'Yes'

另一方面,我們得到更嚴格的類型檢查:

function func(noYes: NoYes) {}    //@ts-ignore: Argument of type '"abc"' is not assignable  //            to parameter of type 'NoYes'.  func('abc');    //@ts-ignore: Argument of type '"Yes"' is not assignable  //            to parameter of type 'NoYes'.  func('Yes');

四、枚舉的用例

4.1 用例:位模式

在 Node.js 文件系統模組中,幾個函數具有參數模式。它的值用於通過 Unix 保留的編碼來指定文件許可權:

  • 為三類用戶指定了許可權:
    • 用戶:文件的所有者
    • 組:與文件關聯的組的成員
    • 全部:所有人
  • 對於每個類別,可以授予以下許可權:
    • r(讀取):允許類別中的用戶讀取文件
    • w(寫):允許類別中的用戶更改文件
    • x(執行):允許類別中的用戶執行文件

這意味著許可權可以用 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,    UserWrite    = 1 << 7,    UserExecute  = 1 << 6,    GroupRead    = 1 << 5,    GroupWrite   = 1 << 4,    GroupExecute = 1 << 3,    AllRead      = 1 << 2,    AllWrite     = 1 << 1,    AllExecute   = 1 << 0,  }

位模式通過按位或(OR)組合:

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

八進位,Octal,縮寫 OCT 或 O,一種以 8 為基數的計數法,採用 0,1,2,3,4,5,6,7 八個數字,逢八進 1。八進位 0o755 對應的十進位值是 493。

4.1.1 對位模式的替代

位模式背後的主要思想是存在一組標誌,並且可以選擇這些標誌的任何子集。因此,使用 Set 選擇子集是執行同一任務的一種更具描述性的方式:

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

4.2 用例:多個常量

有時,我們有一組屬於同類型的常量:

// Log level:  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 只要需要這些常量之一,就可以使用類型,並且 TypeScript 會執行靜態檢查。

4.3 用例:相比布爾值來說更具自我描述性

當使用布爾值表示替代方案時,枚舉通常是一種更具自我描述性的選擇。

4.3.1 布爾型示例:有序列表與無序列表

例如,為了表示列表是否有序,我們可以使用布爾值:

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

但是,枚舉更具有自我描述性,並具有其他好處,即如果需要,我們可以在以後添加更多選擇項。

enum ListKind { ordered, unordered }    class List2 {    listKind: ListKind;    // ···  }
4.3.2 布爾型示例:失敗與成功

同樣,我們可以通過布爾值或枚舉來表示操作是成功還是失敗:

class Result1 {    success: boolean;    // ···  }    enum ResultStatus { failure, success }    class Result2 {    status: ResultStatus;    // ···  }

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

若使用基於字元串的枚舉更為方便:

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

五、運行時枚舉

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

有兩組賦值操作:

  • 前兩個賦值語句將枚舉成員名稱映射到值。
  • 後兩個賦值語句將值映射到名稱。這稱為反向映射,我們將在後面介紹。

5.1 反向映射

給定一個數字枚舉:

enum NoYes {    No,    Yes,  }

普通的映射是從成員名稱到成員值:

// 靜態查找  assert.equal(NoYes.Yes, 1);    // 動態查找  assert.equal(NoYes['Yes'], 1);

數字枚舉還支援從成員值到成員名稱的反向映射:

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

5.2 運行時基於字元串的枚舉

基於字元串的枚舉在運行時具有更簡單的表示形式。

考慮以下枚舉:

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

它會被編譯為以下 JavaScript 程式碼:

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

TypeScript 不支援基於字元串枚舉的反向映射。

六、const 枚舉

如果枚舉以 const 關鍵字為前綴,則在運行時沒有任何表示形式,而是直接使用成員的值。

6.1 編譯非 const 枚舉

首先我們來看一下非 const 枚舉:

enum NoYes {    No,    Yes,  }    function toChinese(value: NoYes) {    switch (value) {      case NoYes.No:        return '否';      case NoYes.Yes:        return '是';    }  }

TypeScript 會將以上程式碼編譯為:

var NoYes;  (function (NoYes) {      NoYes[NoYes["No"] = 0] = "No";      NoYes[NoYes["Yes"] = 1] = "Yes";  })(NoYes || (NoYes = {}));  function toChinese(value) {      switch (value) {          case NoYes.No:              return '否';          case NoYes.Yes:              return '是';      }  }

6.2 編譯 const 枚舉

這與前面的程式碼基本一致,但是使用了 const 關鍵字:

const enum NoYes {    No,    Yes,  }    function toChinese(value: NoYes) {    switch (value) {      case NoYes.No:        return '否';      case NoYes.Yes:        return '是';    }  }

現在,之前生成的 NoYes 對象消失了,僅保留了其成員的值:

function toChinese(value) {      switch (value) {          case 0 /* No */:              return '否';          case 1 /* Yes */:              return '是';      }  }

七、編譯時枚舉

7.1 枚舉是對象

TypeScript 將(非 const)枚舉視為對象:

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

7.2 字面量枚舉全面性檢查

當我們接受一個枚舉成員值時,我們通常要確保:

  • 我們沒有收到非法的值;
  • 我們沒有遺漏任何枚舉成員的值。(如果以後再添加新的枚舉成員時,這一點尤為重要。)
7.2.1 抵禦非法值

在以下程式碼中,我們針對非法值採取了兩種措施:

enum NoYes {    No = 'No',    Yes = 'Yes',  }    function toChinese(value: NoYes) {    switch (value) {      case NoYes.No:        return '否';      case NoYes.Yes:        return '是';      default:        throw new TypeError('Unsupported value: ' + JSON.stringify(value));    }  }    assert.throws(    //@ts-ignore: Argument of type '"Maybe"' is not assignable to    //            parameter of type 'NoYes'.    () => toChinese('Maybe'),    /^TypeError: Unsupported value: "Maybe"$/);

這些措施是:

  • 在編譯時,該類型 NoYes 可防止將非法值傳遞給 value 參數;
  • 在運行時,如果含有其它值,則 default 分支會拋出異常。
7.2.2 通過全面性檢查抵禦遺漏場景

我們可以再採取一種措施。以下程式碼執行全面性檢查:如果我們忘記考慮所有枚舉成員,TypeScript 將警告我們。

enum NoYes {    No = 'No',    Yes = 'Yes',  }    function throwUnsupportedValue(value: never): never {    throw new TypeError('Unsupported value: ' + value);  }    function toChinese2(value: NoYes) {    switch (value) {      case NoYes.No:        return '否';      case NoYes.Yes:        return '是';      default:        throwUnsupportedValue(value);    }  }

全面性檢查如何工作?對於每種情況,TypeScript 都會推斷 value 的類型:

function toGerman2b(value: NoYes) {    switch (value) {      case NoYes.No:        const x: NoYes.No = value;        return '否';      case NoYes.Yes:        const y: NoYes.Yes = value;        return '是';      default:        const z: never = value;        throwUnsupportedValue(value);    }  }

在 default 分支中,TypeScript 會推斷 value 的類型為 never 類型。但是,如果我們添加一個成員 MaybeNoYes 枚舉中,之後 value 的推斷類型是 NoYes.Maybe,這時該變數的類型與 throwUnsupportedValue() 方法中參數的類型在靜態上不兼容。因此,我們在編譯時會收到以下錯誤消息:

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

幸運的是,這種全面性檢查也適用於以下 if 語句:

function toGerman3(value: NoYes) {    if (value === NoYes.No) {      return '否';    } else if (value === NoYes.Yes) {      return '是';    } else {      throwUnsupportedValue(value);    }  }
7.2.3 全面性檢查的另一種方法

另外,如果我們為以下 toChinese() 函數指定返回類型,也可以實現全面性檢查:

enum NoYes {    No = 'No',    Yes = 'Yes',  }    function toChinese(value: NoYes): string {    switch (value) {      case NoYes.No:        const x: NoYes.No = value;        return '否';      case NoYes.Yes:        const y: NoYes.Yes = value;        return '是';    }  }

如果我們向 NoYes 中添加成員,則 TypeScript 會提醒 toChinese() 方法可能會返回 undefined

這種方法的缺點: 這種方法不適用於 if 語句。

7.3 keyof 和枚舉

我們可以使用 keyof 類型運算符創建類型,其元素是枚舉成員的 key。當我們這樣做,我們需要結合 keyoftypeof 一起使用:

enum HttpRequestKeyEnum {    'Accept',    'Accept-Charset',    'Accept-Datetime',    'Accept-Encoding',    'Accept-Language',  }    type HttpRequestKey = keyof typeof HttpRequestKeyEnum;    // = 'Accept' | 'Accept-Charset' | 'Accept-Datetime' |    //   'Accept-Encoding' | 'Accept-Language'    function getRequestHeaderValue(request: Request, key: HttpRequestKey) {    // ···  }

為什麼這樣?這比直接定義 HttpRequestKey 類型更方便。

7.3.1 使用 keyof 不使用 typeof

如果使用 keyof 不使用 typeof,則會得到另一個不太有用的類型:

type Keys = keyof HttpRequestKeyEnum;    // = 'toString' | 'toFixed' | 'toExponential' |    //   'toPrecision' | 'valueOf' | 'toLocaleString'

keyof HttpRequestKeyEnum 的結果與 keyof number 相同。