TypeScript 的 enum、const enum 和联合类型怎样选?
enum 会编译成运行时对象,数字枚举还带反向映射;const enum 在使用处内联成常量,但单文件转译和发布 .d.ts 时有坑;字符串字面量联合或 as const 对象完全可擦除。开启 isolatedModules 或 erasableSyntaxOnly 的项目,优先用联合类型和 as const 对象。
简答
订单状态有“待支付、已支付、已取消”几种取值,可以写
enum OrderStatus,也可以写type OrderStatus = 'pending' | 'paid' | 'cancelled'。区别在于 enum 会编译出一个真实的 JavaScript 对象,联合类型编译后什么都不剩。普通 enum 适合需要在运行时遍历取值或按名字取值的场景,数字枚举还会生成反向映射,能从值查到名字。
const enum不生成对象,而是在使用的地方直接替换成常量值,体积更小。可是
const enum依赖编译器读到别的文件,Babel、esbuild 这类一次只处理一个文件的工具做不到,所以isolatedModules下引用外部声明的const enum会报错。TypeScript 5.8 加的erasableSyntaxOnly更进一步,所有 enum 声明都会报错,因为它们没法通过“删掉类型”变回 JavaScript。所以新项目通常用字符串字面量联合;需要运行时取值列表时,用
as const对象再推出联合类型。要注意的是,字符串枚举和普通字符串不能互相赋值,接口返回的'paid'不能直接当成OrderStatus.Paid用。
enum 编译后变成了什么
先看一段 TypeScript 和它编译出的 JavaScript。下面的输出来自 TypeScript 7.0.2(--target es2020 --module esnext):
export enum Status { Idle, Loading }
export enum Color { Red = 'RED' }
export const enum Dir { Up, Down }
console.log(Status.Loading, Status[1], Dir.Down);
export var Status;
(function (Status) {
Status[Status["Idle"] = 0] = "Idle";
Status[Status["Loading"] = 1] = "Loading";
})(Status || (Status = {}));
export var Color;
(function (Color) {
Color["Red"] = "RED";
})(Color || (Color = {}));
console.log(Status.Loading, Status[1], 1 /* Dir.Down */);
数字枚举 Status 这一行写法有点绕:Status["Idle"] = 0 先存“名字到值”,这个赋值表达式的结果是 0,再拿它做键存“值到名字”。这就是官方文档说的反向映射。字符串枚举 Color 没有反向映射。Dir 是 const enum,整个声明消失了,Dir.Down 直接变成了 1。
把编译结果放进 node 运行,就能看到反向映射带来的副作用:
var Status;
(function (Status) {
Status[Status['Idle'] = 0] = 'Idle';
Status[Status['Loading'] = 1] = 'Loading';
})(Status || (Status = {}));
var Color;
(function (Color) {
Color['Red'] = 'RED';
})(Color || (Color = {}));
console.log(Status.Loading); // 1
console.log(Status[1]); // Loading
console.log(Object.keys(Status)); // [ '0', '1', 'Idle', 'Loading' ]
console.log(Object.keys(Color)); // [ 'Red' ]
如果想用 Object.keys 生成下拉选项,数字枚举会多出 '0'、'1' 两个键,要先过滤掉。字符串枚举没有这个问题。
const enum 为什么会和 isolatedModules 冲突
const enum 的内联要求编译器知道成员的值。tsc 编译整个项目,能读到声明;可 Babel、esbuild、以及 TypeScript 自己的 ts.transpileModule 一次只看一个文件,遇到从别的文件来的 Numbers.Zero,并不知道它等于几,也没法内联。运行时又没有 Numbers 这个对象,就会报错。
isolatedModules 的作用是提前把这种代码标出来。官方文档的说法是:开启后,引用环境 const enum(ambient const enum,基本就是 .d.ts 里的 const enum)的成员会报错。它本身不改变代码的编译输出。另外从 TSConfig 参考可以看到,开启 isolatedModules 时 preserveConstEnums 默认为 true,项目自己写的 const enum 会保留一份运行时对象,上面那个例子用 --isolatedModules 编译,Dir.Down 就不再内联,而是保留成 Dir.Down。
官方文档还专门写了“Const enum pitfalls”:如果你发布的库在 .d.ts 里带 const enum,下游开了 isolatedModules 就用不了;而且下游编译时内联的是 A 版本的值,运行时装的却可能是 B 版本,值不一致会走错分支。文档给的两种办法是干脆不用 const enum,或者用 preserveConstEnums 并在发布的 .d.ts 里去掉 const。
erasableSyntaxOnly 为什么连普通 enum 也不让写
Node.js 从 23.6 起默认支持直接运行 TypeScript 文件,做法是把类型语法擦掉。这要求文件里的 TypeScript 专有语法都没有运行时含义。enum 会生成对象,删掉就丢了代码,所以不支持。
TypeScript 5.8 为此加了 erasableSyntaxOnly。开启后,enum、带运行时代码的 namespace、类的参数属性、import =/export = 和 <number>x 这种尖括号断言都会报错。用上面的文件实际验证,三个 enum 声明(包括 const enum)都报 TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
不用 enum 时怎样写
官方文档“Objects vs Enums”一节的建议是:很多时候一个 as const 对象就够了。
export const Role = { Admin: 'admin', User: 'user' } as const;
export type Role = (typeof Role)[keyof typeof Role]; // 'admin' | 'user'
function can(role: Role) {}
can(Role.Admin);
can('user'); // 也可以,直接写字面量
对象在运行时还在,能遍历、能按名字取值;类型由对象推出来,两者不会写得不一致。只需要类型、不需要运行时列表时,直接写 type Size = 'sm' | 'md' 最简单。这两种写法都能被擦除,开着 erasableSyntaxOnly 也没问题。
还有一个差别容易被忽略:字符串枚举是“名义上”的,const c: Color = 'RED' 会报错,必须写 Color.Red。接口返回的是普通字符串,用字符串枚举时就得做一次转换或断言;用字面量联合则可以直接比较。
常见追问
现在还有必要用 enum 吗?
项目只用 tsc 编译、没有开
isolatedModules和erasableSyntaxOnly,用普通 enum 没有问题。但如果项目由 Vite、esbuild 或 Babel 转译,或者想用 Node.js 直接运行.ts,联合类型和as const对象的限制更少。
数字枚举能接受任意数字吗?
字面量不行。用 TypeScript 7.0.2 编译时,
const s: Status = 5报Type '5' is not assignable to type 'Status',只接受枚举里声明过的值。
declare enum 和 const enum 有什么不同?
declare enum是环境枚举,只描述“运行时某处已经有这个对象”,不生成代码。const enum则是让编译器内联它的值。两者组合成的declare const enum就是上文说的最容易出问题的环境 const enum。