TypeScript 列舉與值型別

最後更新: 2021-02-20 06:14:56 +0800 +0800
原文以英文撰寫。您正在閱讀AI翻譯版本。

封閉集合可能需要編譯時限制、執行時查找物件、線上表示法,或具備已驗證行為的領域物件。由於這些需求並不等價,TypeScript 提供了不同的表示方式。

分離型別需求與執行時識別 #

如下型別:

ts
type Status = "pending" | "running" | "done";

只存在於型別檢查期間。JavaScript 無法列舉 Status、測試未知字串是否屬於它,或為它附加中繼資料。執行時程式碼需要對應的值或驗證函式。

相較之下,原生 enum 會輸出 JavaScript 物件並引入列舉型別。as const 表會輸出一般物件,並從中推導型別。類別會輸出建構函式,且能強制執行不變量。應根據程式實際需要的執行時識別進行選擇。

理解原生列舉的輸出 #

數值列舉會指派數字,並輸出正向與反向對映:

ts
enum StatusCode {
  Pending,
  Running,
  Done,
}

console.log(StatusCode.Running); // 1
console.log(StatusCode[1]);      // "Running"

產生的物件同時支援從名稱查找數字,以及從數字查找名稱。字串列舉只輸出從名稱到值的屬性:

ts
enum Status {
  Pending = "pending",
  Running = "running",
  Done = "done",
}

自 TypeScript 5.0 起,每個列舉成員都有獨立的成員型別,包括從計算值初始化的成員。這可支援型別縮小與窮舉式 switch,但不會改變輸出的物件。

const enum 要求 TypeScript 將成員值內嵌並消除列舉:

ts
const enum Direction {
  Left,
  Right,
}

內嵌可能與 isolated-module 轉譯器衝突,也可能與這類函式庫衝突:其發佈的宣告生命週期長於用來編譯取用者的確切列舉值。除非能控制整個工具鏈與版本策略,否則應避免跨越套件邊界匯出 ambient const enum 宣告。

對僅限型別的集合使用字面值聯集 #

字面值聯集不需要輸出的列舉機制即可符合 JSON 形狀的 API:

ts
type Status = "pending" | "running" | "done";

function label(status: Status): string {
  switch (status) {
    case "pending":
      return "Pending";
    case "running":
      return "Running";
    case "done":
      return "Done";
  }
}

控制流程分析會在每個分支中縮小 status。聯集也能區分攜帶不同資料的變體:

ts
type Payment =
  | { kind: "card"; brand: string; lastFour: string }
  | { kind: "cash" }
  | { kind: "wire"; iban: string };

在 payment.kind === "card" 之後,編譯器會公開 brand 和 lastFour。聯集會檢查已經擁有 Payment 的程式碼,但不會在執行時驗證不受信任的 JSON 值。

從 as const 表推導型別 #

值表能將執行時驗證與編譯時聯集繫結至單一定義:

ts
const Status = {
  Pending: "pending",
  Running: "running",
  Done: "done",
} as const satisfies Record<string, string>;

type StatusName = keyof typeof Status;
type Status = (typeof Status)[StatusName];

const statuses: ReadonlySet<string> = new Set(Object.values(Status));

function isStatus(value: unknown): value is Status {
  return typeof value === "string" && statuses.has(value);
}

as const 會防止屬性值擴大為 string。satisfies 會檢查表的形狀,同時保留這些字面值型別。唯讀集合刻意接受任何字串以進行查找;typeof 檢查與集合成員檢查則提供型別述詞所需的執行時證明。

當順序是首要需求時,使用陣列:

ts
const statuses = ["pending", "running", "done"] as const;
type Status = (typeof statuses)[number];

當穩定的符號鍵、中繼資料查找,或與現有成員存取方式的相容性是首要需求時,使用物件。

將已驗證的值建模為物件 #

值物件代表一個領域值,其不變量與行為結合在一起。例如,百分比不只是任意數字:

ts
class Percentage {
  private constructor(readonly value: number) {}

  static from(value: number): Percentage {
    if (!Number.isFinite(value) || value < 0 || value > 100) {
      throw new RangeError("percentage must be finite and between 0 and 100");
    }
    return new Percentage(value);
  }

  asRatio(): number {
    return this.value / 100;
  }

  equals(other: Percentage): boolean {
    return this.value === other.value;
  }

  toJSON(): number {
    return this.value;
  }
}

私有建構函式會強制呼叫端經過驗證。方法為領域操作命名,而 toJSON 定義邊界表示法。由於 JSON 解析會回傳數字,而不是類別執行個體,因此反序列化必須再次呼叫 Percentage.from。

不要只為了包裝標籤而使用類別。當物件能保護不變量、提供行為,或避免結構相似的基本型別相互混用時,配置與轉換程式碼才具有正當性。

明確設計序列化邊界 #

線上格式應使用穩定且有文件說明的字串或數字,而不是依賴 TypeScript 輸出的物件形狀。數值列舉的自動遞增值容易因成員重新排序而失效;反向對映是實作細節,不是協定。

在輸入邊界執行以下步驟:

  1. 解析不受信任的表示法。
  2. 驗證成員關係或建構值物件。
  3. 在應用程式內保留已驗證的型別。
  4. 寫入 JSON、資料庫值或訊息時,刻意進行轉換。

當取用者需要列舉輸出的執行時識別,且工具鏈接受該輸出時,使用原生列舉。對於僅限型別的集合,使用字面值聯集;當程式碼也需要執行時值時,使用 as const 表;對於變體特定資料,使用可辨識聯集;對於驗證與行為,使用值物件。

評論

在 GitHub 上閱讀或評論(發表評論需要登入)