TypeScript 中的 Enum 和 Union String Literal

1. 概述

当你需要表示一组固定的值,比如订单状态或支付方式时,TypeScript 为你提供了两种常见选择:enum 和 union string literal('PENDING' | 'PAID')。这两种方式在编辑器中看起来相似,但在运行时、类型检查器的处理方式以及与现代工具链的兼容性方面存在差异。

本文通过具体示例分析每一种方式,指出常见的错误,并介绍结合两者优点的 as const 模式。读完后,你会有清晰的标准来为自己的项目做出选择。

2. TypeScript 中的 Enum

2.1. String Enum

enum OrderStatus {
  Pending = 'PENDING',
  Paid = 'PAID',
  Shipped = 'SHIPPED',
  Cancelled = 'CANCELLED',
}

function canCancel(status: OrderStatus): boolean {
  return status === OrderStatus.Pending || status === OrderStatus.Paid;
}

console.log(canCancel(OrderStatus.Paid));
console.log(Object.values(OrderStatus));

通过 npx tsx order.ts 运行,你会看到:

true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]

重点是:enum 不仅仅是类型。编译器会在运行时生成一个真实的对象。这就是为什么你能调用 Object.values(OrderStatus)。生成的 JavaScript 代码看起来是这样的:

var OrderStatus;
(function (OrderStatus) {
  OrderStatus["Pending"] = "PENDING";
  OrderStatus["Paid"] = "PAID";
  OrderStatus["Shipped"] = "SHIPPED";
  OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));

2.2. 数值 Enum 和反向映射

如果不指定值,enum 会从 0 开始编号:

enum Priority {
  Low,
  Medium,
  High,
}

console.log(Priority.High);
console.log(Priority[2]);
console.log(Object.keys(Priority));
2
High
[ '0', '1', '2', 'Low', 'Medium', 'High' ]

数值 Enum 有反向映射:Priority[2] 会返回 'High'。结果是 Object.keys 会返回数字和名称两个部分。当你从 enum 渲染下拉列表时,这是一个经典错误,你会看到列表被重复。

注意: 数值 Enum 仍然接受任何 number 类型的变量。例如 const p: Priority = someNumber; 能正常编译,即使 someNumber 是 99。从 TypeScript 5.0 开始,分配超出范围的字面量(如 const p: Priority = 99;)才会报错。number 类型的变量仍然会通过检查。

2.3. Enum 的“名义”特性

String Enum 不接受对应的字符串字面量:

canCancel(OrderStatus.Paid); // 正常
canCancel('PAID');           // 错误:类型 '"PAID"' 的参数不能分配给 'OrderStatus' 类型的参数

你可以认为这是一个优点:强制任何地方都必须通过 OrderStatus。但当数据来自 API 的 JSON 时,你必须进行类型强制转换(as OrderStatus)或编写转换函数。

3. Union String Literal

type PaymentMethod = 'CARD' | 'BANK_TRANSFER' | 'COD';

interface Payment {
  orderId: string;
  method: PaymentMethod;
  amount: number;
}

const payment: Payment = { orderId: 'ORD-001', method: 'COD', amount: 250_000 };
console.log(payment.method);

// const wrong: Payment = { orderId: 'ORD-002', method: 'PAYPAL', amount: 1 };
// 错误:类型 '"PAYPAL"' 不能分配给 'PaymentMethod' 类型
COD

Union 类型在编译时被完全删除。生成的 JavaScript 只包含 payment 对象和字符串 'COD',没有多余代码。编辑器在你输入 method: ' 时仍会提示这三个值。

弱点是:由于在运行时不存在,你无法遍历 PaymentMethod 的值。如果不单独声明一个列表,你也无法验证输入。

3.1. 使用 as const 获取值列表

解决方案是先声明一个数组,然后从该数组推导类型:

export const PAYMENT_METHODS = ['CARD', 'BANK_TRANSFER', 'COD'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];

export function isPaymentMethod(value: string): value is PaymentMethod {
  return (PAYMENT_METHODS as readonly string[]).includes(value);
}

console.log(PAYMENT_METHODS);
console.log(isPaymentMethod('COD'), isPaymentMethod('PAYPAL'));
[ 'CARD', 'BANK_TRANSFER', 'COD' ]
true false

解释:

  • as const 保留每个元素的字面量类型。数组的类型是 readonly ['CARD', 'BANK_TRANSFER', 'COD'],而不是 string[]。
  • (typeof PAYMENT_METHODS)[number] 获取所有元素的 union 类型,即 'CARD' | 'BANK_TRANSFER' | 'COD'。
  • isPaymentMethod 是一个类型守卫。检查后,TypeScript 会将 value 收窄到 PaymentMethod。
  • 强制转换为 readonly string[] 是必要的,因为对元组字面量的 includes 只接受这些字面量本身。

从现在开始,列表和类型始终保持同步。向数组添加元素时,类型会自动更新。

4. as const 对象模式:折中方案

许多团队喜欢 enum 的 OrderStatus.Paid 语法,但不想要它的缺点。你可以使用 as const 对象:

export const OrderStatus = {
  Pending: 'PENDING',
  Paid: 'PAID',
  Shipped: 'SHIPPED',
  Cancelled: 'CANCELLED',
} as const;

export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

function canCancel(status: OrderStatus): boolean {
  return status === OrderStatus.Pending || status === OrderStatus.Paid;
}

console.log(canCancel(OrderStatus.Paid));
console.log(canCancel('SHIPPED'));
true
true

在这里:

  • 同一个名称 OrderStatus 被用作值(对象)和类型。TypeScript 允许这样做,因为值和类型位于不同的命名空间中。
  • keyof typeof OrderStatus 是 'Pending' | 'Paid' | ...。索引访问返回值的 union 'PENDING' | 'PAID' | ...。
  • 你仍然可以写 OrderStatus.Paid,同时直接传入 JSON 中的 'SHIPPED' 而不需要类型强制转换。
  • 生成的代码只是一个普通的对象字面量,没有 IIFE(立即执行函数表达式),也没有反向映射。

5. 用 never 进行穷尽检查

Enum 和 union 都支持“是否已处理所有情况”的检查。这是使用固定值集而不是 string 的主要原因。

import { OrderStatus } from './order-status';

export function statusLabel(status: OrderStatus): string {
  switch (status) {
    case 'PENDING':
      return '待支付';
    case 'PAID':
      return '已支付';
    case 'SHIPPED':
      return '配送中';
    case 'CANCELLED':
      return '已取消';
    default: {
      const unreachable: never = status;
      throw new Error(`无效的状态:${unreachable}`);
    }
  }
}

如果稍后你向 OrderStatus 添加 Refunded: 'REFUNDED' 但忘记添加对应的 case,行 const unreachable: never = status; 会报错:

Type 'string' is not assignable to type 'never'.

更准确地说,消息会说 '"REFUNDED"' is not assignable to type 'never'。错误在编译时就会出现,在代码上线到生产环境之前。throw 分支在运行时仍然很有用,当来自外部的陌生数据进入时。

6. 为主要行为编写测试

安装 Vitest:

npm install -D vitest

为类型守卫和 statusLabel 函数编写测试:

import { describe, expect, it } from 'vitest';
import { isPaymentMethod, PAYMENT_METHODS } from './payment-method';
import { statusLabel } from './status-label';
import { OrderStatus } from './order-status';

describe('PaymentMethod', () => {
  it('接受列表中的所有值', () => {
    for (const method of PAYMENT_METHODS) {
      expect(isPaymentMethod(method)).toBe(true);
    }
  });

  it('拒绝无效值', () => {
    expect(isPaymentMethod('PAYPAL')).toBe(false);
    expect(isPaymentMethod('cod')).toBe(false);
  });
});

describe('statusLabel', () => {
  it('返回所有状态的标签', () => {
    const labels = Object.values(OrderStatus).map(statusLabel);
    expect(labels).toEqual(['待支付', '已支付', '配送中', '已取消']);
  });

  it('当运行时数据无效时抛出错误', () => {
    expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('无效的状态:REFUNDED');
  });
});

运行 npx vitest run:

 ✓ PaymentMethod > 接受列表中的所有值
 ✓ PaymentMethod > 拒绝无效值
 ✓ statusLabel > 返回所有状态的标签
 ✓ statusLabel > 当运行时数据无效时抛出错误

 Test Files  1 passed (1)
      Tests  4 passed (4)

第二个测试证明了类型守卫区分大小写。这是在解析表单输入时容易忽视的细节。

7. 现代工具链和 const enum

越来越多的工具只删除类型而不是进行完整的 TypeScript 编译。例如:Node.js 的 type stripping 模式,或 esbuild 和 SWC 在单独处理每个文件时。这些工具无法处理生成运行时代码的语法,如 enum。

TypeScript 5.8 有一个 erasableSyntaxOnly 标志来禁止这类语法:

{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "isolatedModules": true
  }
}

启用该标志后,任何 enum 声明都会报错。Union literal 和 as const 对象仍然有效,因为它们是纯 JavaScript 加上类型注解。

const enum 曾被宣传为“零成本” enum,因为编译器会内联值而不是生成对象。然而,它会导致许多问题:

  • 与 isolatedModules 一起不安全,因为单文件编译工具不知道其他文件中 enum 的值。
  • 发布库时,.d.ts 文件中的 const enum 可能会破坏用户的代码,如果他们不使用 tsc。

注意: 在新代码中避免使用 const enum,特别是在库中。如果想要零运行时成本,union literal 已经可以做到这一点,而且没有风险。

8. 何时使用哪种方式?

标准 enum Union literal Object as const
在运行时有值 有 没有(需要单独的数组) 有
接受来自 JSON 的原始字符串 必须强制转换 是 是
兼容 type stripping 否 是 是
X.Value 语法 是 否 是
穷尽检查 是 是 是

实用建议:

  • Union literal:在大多数情况下作为默认选择,特别是 props、DTO 和函数参数的类型。最简洁,没有运行时代码。
  • 数组 as const + union:当你需要遍历值(下拉菜单、验证输入)或与验证库一起使用时。
  • Object as const:当团队希望使用类似 enum 的语法,或在代码中的显示名称不同于实际值时(Pending vs 'PENDING')。
  • enum:当代码库已一致使用且用完整的 tsc 构建时,或当你真正想要名义类型时,即禁止传递原始字符串。

常见错误:

  • 在数值 enum 上使用 Object.keys 并由于反向映射而获得重复列表。
  • 允许 numeric enum 接受任意 number,然后当有人在 enum 中间插入新成员时,数据库中存储的数据会错位。顺序的改变会导致值的改变。
  • 在接收 API 数据时立即进行 as OrderStatus 强制转换,而不是用类型守卫检查。类型检查器会相信你,但错误的数据会继续传播。
  • 忘记 as const,导致类型被扩展为 string 并完全失效。

9. 结论

Enum 和 union string literal 都解决了“固定值集”的问题。区别在于 enum 生成运行时代码并具有名义类型。Union literal 仅存在于类型层,所以更轻量且对现代工具链更友好。

对于新的 TypeScript 代码,你应该从 union literal 开始。当需要运行时列表时,再结合数组或 as const 对象。无论选择哪种方式,都应在系统边界处使用 never 的穷尽检查和类型守卫。这才是真正帮助代码安全的部分。

typescriptenumunion typetype safetybest practices