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 的语法,或在代码中的显示名称不同于实际值时(Pendingvs'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 的穷尽检查和类型守卫。这才是真正帮助代码安全的部分。