我用数字枚举定义订单状态,以为类型安全了,结果一个不存在的数字 99 也能传进去、遍历枚举时还莫名多出一倍的项:一次 TypeScript 数字枚举两个坑的深度复盘
那两个 bug 是状态逻辑错乱和遍历出错才暴露的:我用 TypeScript 的数字枚举定义订单状态:enum OrderStatus { Pending, Paid, Shipped }(值默认是 0、1、2)。我以为"用了枚举、类型就安全了"。可线上出了两件怪事:第一,有个函数参数类型是 OrderStatus,结果一个不属于这个枚举的数字 99 被传了进去,TS 居然不报错,导致后面 switch 判断走到了谁也没料到的分支;第二,我用 Object.keys(OrderStatus) 想遍历所有状态名,结果遍历出来的项数是枚举成员的两倍,还混着数字和字符串。我查了 TS 的枚举编译产物,才看明白,后背发凉:这两个坑,都源于 TS 数字枚举的特性。坑一:数字枚举本质上就是 number,类型不安全。OrderStatus 的底层类型是 number,而 TS 为了兼容,允许任意 number 赋值给数字枚举类型(它不会检查这个数字"是不是枚举里定义过的那几个值");所以 99 as OrderStatus 甚至直接传 99,TS 都放行;坑二:数字枚举会生成"反向映射"。TS 把数字枚举编译成一个双向映射的对象:既有 {Pending: 0, Paid: 1, Shipped: 2}(名→值),又有 {0: "Pending", 1: "Paid", 2: "Shipped"}(值→名);所以 Object.keys 遍历出来的,既有名字、又有数字,是成员数的两倍。根本原因是:TS 的数字枚举为了和 number 兼容、为了支持双向查找,做了"接受任意 number"和"反向映射"这两个设计,而它们恰恰违背了"类型安全"和"干净遍历"的直觉。问题的根,是用了数字枚举:它本质是 number、接受任意数字赋值(不安全),且会生成反向映射污染遍历。这篇就把这次"数字枚举两个坑"的坑,从头到尾复盘一遍。
故障现场:数字 99 能传进枚举、遍历多一倍
问题在于数字枚举本质是 number(接受任意数字)且生成反向映射:
// ✗ 出问题的代码: 用数字枚举
enum OrderStatus {
Pending, // = 0
Paid, // = 1
Shipped, // = 2
}
// 坑一: 数字枚举本质是number, 任意数字都能赋值进去, TS不报错
function handle(status: OrderStatus) {
switch (status) {
case OrderStatus.Pending: /* ... */ break;
case OrderStatus.Paid: /* ... */ break;
case OrderStatus.Shipped: /* ... */ break;
// 没有default → 99落到这里时, 什么也没匹配, 静默走过
}
}
const bad: OrderStatus = 99; // ✗ TS不报错! 99根本不是枚举成员, 但数字枚举接受任意number
handle(bad); // 传了99进去 → switch全不匹配 → 逻辑错乱
// 坑二: 数字枚举生成"反向映射", 遍历多一倍
console.log(Object.keys(OrderStatus));
// ✗ 输出: ["0", "1", "2", "Pending", "Paid", "Shipped"] ← 6项! 成员只有3个, 多了一倍!
// 因为TS把数字枚举编译成双向映射:
// OrderStatus = { 0:"Pending", 1:"Paid", 2:"Shipped", Pending:0, Paid:1, Shipped:2 }
// → Object.keys既拿到数字键("0","1","2")又拿到名字键("Pending"...) → 遍历就乱了。
// 为什么会这样? TS数字枚举的两个设计:
// 1. 数字枚举的类型本质是number, 为兼容允许任意number赋值给它(不校验是否为合法成员值);
// → 失去了"只能是这几个值"的类型安全(它本该提供的);
// 2. 数字枚举编译时生成"值→名"的反向映射(为了能 OrderStatus[0] 拿到 "Pending");
// → 这个反向映射让枚举对象的键变成两倍(数字键+名字键), 直接Object.keys/values遍历就出错。
// 关键: TS数字枚举本质是number(接受任意数字赋值, 不安全)、且生成反向映射(遍历多一倍);
// 想要"只能是这几个值"的类型安全和干净遍历, 数字枚举做不到。
第一次看懂"数字枚举就是个 number、还偷偷生成了反向映射"时,我又懊恼又意外:"我用枚举,图的就是'状态只能是这几个、类型安全',结果它既不拦不合法的数字、遍历还多出一倍,完全没达到我要的效果。"这个坑最隐蔽的地方在于:它违背了我用枚举的初衷——我以为枚举=类型安全的有限取值,可数字枚举恰恰在"类型安全"上不可靠(接受任意数字);而反向映射的坑,只在你去遍历枚举对象时才暴露,平时用枚举成员毫无异样。下面就来拆解,枚举该怎么用才安全。
第一件事:搞懂数字枚举、字符串枚举与 as const 的区别
我顺着这次事故,把 TS 表示"有限取值"的几种方式彻底理清了。
TS 表示"有限取值"的几种方式: 数字枚举 / 字符串枚举 / as const 联合类型
【核心: 数字枚举本质number(接受任意数字)+反向映射(遍历乱); 字符串枚举没这俩问题; as const联合字面量更轻量类型安全; 优先后两者】
1. 数字枚举(enum E { A, B }): 两个坑
- 本质是number: 允许任意number赋值(99也行), 类型不安全(不校验是否合法成员);
- 反向映射: 编译成双向map(名↔值), Object.keys/values遍历会多出数字键, 遍历乱;
- 优点: 值连续、节省、可反向查名(OrderStatus[0]); 但代价是上面两个坑。
2. 字符串枚举(enum E { A = "A", B = "B" }): 没有那两个坑
- 值是字符串, 不允许任意字符串赋值(只能是定义的成员) → 类型安全;
- 不生成反向映射 → Object.keys只有名字, 遍历干净;
- 缺点: 要手写每个值; 但安全性值得。
3. as const 联合字面量类型(推荐, 最轻量): 不用enum
- const OrderStatus = { Pending: "pending", Paid: "paid", Shipped: "shipped" } as const;
- type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus]; // "pending"|"paid"|"shipped"
- 或直接: type OrderStatus = "pending" | "paid" | "shipped";
- 优点: 就是普通对象/联合类型, 无enum的运行时开销和反向映射坑, 类型安全(只能是这几个字面量);
- TS社区很多人推荐用 as const + 联合类型 代替 enum。
4. 怎么选:
- 别用数字枚举(那两个坑);
- 要用enum就用字符串枚举(安全、遍历干净);
- 更推荐: as const 对象 + 联合字面量类型(轻量、安全、无运行时坑);
- 需要遍历所有取值: 用字符串枚举的Object.values, 或as const对象的Object.values(都干净)。
5. 补充: switch处理枚举时加default/穷尽检查
- 用 never 做穷尽性检查: default分支里 const _exhaustive: never = status; 若漏了某个case编译报错;
- 防止"漏处理某个状态"或"传进非法值"时静默走过。
一句话: TS数字枚举本质number(接受任意数字赋值不安全)且有反向映射(遍历乱); 用字符串枚举或
as const联合字面量类型代替(类型安全、遍历干净、无运行时坑); switch加default/never穷尽检查。
这套认知,是整个坑的根。数字枚举(enum E{A,B}):两个坑——本质是 number(允许任意 number 赋值,不安全)、生成反向映射(Object.keys/values 遍历多出数字键,乱);优点是值连续可反向查名,但代价是这两个坑。字符串枚举(enum E{A="A"}):没那两个坑——不允许任意字符串赋值(类型安全)、不生成反向映射(遍历干净);缺点是要手写每个值。as const 联合字面量类型(推荐、最轻量)——{...} as const + 联合类型,无 enum 的运行时开销和反向映射坑、类型安全,社区推荐用它代替 enum。怎么选:别用数字枚举;要 enum 就用字符串枚举;更推荐 as const 联合字面量。补充:switch 处理枚举加 default/never 穷尽检查(漏 case 编译报错)。一句话:TS 数字枚举本质 number(接受任意数字赋值不安全)且有反向映射(遍历乱);用字符串枚举或 as const 联合字面量类型代替(类型安全、遍历干净、无运行时坑);switch 加 default/never 穷尽检查。
第二件事:正解——用字符串枚举或 as const 联合字面量,加穷尽检查
搞懂了原理,正解就清晰了:别用数字枚举,改用字符串枚举(安全、遍历干净)或 as const 联合字面量类型(更轻量);switch 用 never 做穷尽性检查防漏防非法。
// ====== 正解一: 用 as const 联合字面量类型(推荐, 最轻量) ======
const OrderStatus = {
Pending: "pending",
Paid: "paid",
Shipped: "shipped",
} as const;
type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus]; // "pending"|"paid"|"shipped"
const bad: OrderStatus = "unknown"; // ✓ 编译报错! 只能是那三个字面量, 不安全的值进不来
const ok: OrderStatus = "paid"; // ✓
console.log(Object.values(OrderStatus)); // ✓ ["pending","paid","shipped"] 干净, 无反向映射
// ====== 正解二: 用字符串枚举(也安全、遍历干净) ======
enum OrderStatus2 {
Pending = "pending",
Paid = "paid",
Shipped = "shipped",
}
const x: OrderStatus2 = "pending" as any; // 直接赋字符串不行(需是成员); 类型安全
console.log(Object.keys(OrderStatus2)); // ✓ ["Pending","Paid","Shipped"] 无反向映射
// ====== 正解三: switch 用 never 做穷尽性检查 ======
function handle(status: OrderStatus): string {
switch (status) {
case "pending": return "待处理";
case "paid": return "已支付";
case "shipped": return "已发货";
default:
// ★ 穷尽性检查: 如果上面漏了某个case, status在这里就不是never, 编译报错;
// 也能在传进非法值时(若类型被绕过)暴露问题。
const _exhaustive: never = status;
throw new Error(`未处理的状态: ${status}`);
}
}
// → 将来给OrderStatus加了新成员(如"refunded")但忘了在switch里处理, 这里会编译报错提醒你。
// ====== 选型与要点 ======
// 1. 别用数字枚举(接受任意数字+反向映射两坑); 要用enum就用字符串枚举;
// 2. 更推荐 as const + 联合字面量类型: 轻量、类型安全、无运行时开销和反向映射坑;
// 3. switch处理有限取值时, 用 never 做穷尽性检查(default里 const _: never = x), 防漏case;
// 4. 需要遍历所有取值: as const对象的Object.values, 或字符串枚举的Object.values, 都干净;
// 5. 从外部(JSON/接口)拿到的值要赋给这些类型时, 在边界做校验(同前面篇), 别直接as断言。
// 核心: 用字符串枚举或 as const 联合字面量类型 代替数字枚举(获得真正的类型安全和干净遍历);
// switch 加 never 穷尽检查防漏case; 表示"有限取值"要选真正能约束取值范围、且无运行时坑的方式。
修复的核心,是"别用数字枚举,改用字符串枚举或 as const 联合字面量,加穷尽检查"。正解一:as const 联合字面量(推荐)——{...} as const + typeof[keyof typeof],只能是那几个字面量、不安全的值编译报错,Object.values 干净无反向映射。正解二:字符串枚举(也类型安全、不生成反向映射)。正解三:switch 用 never 穷尽检查——default 里 const _: never = status,漏了 case 或加了新成员忘处理就编译报错。选型:别用数字枚举、要 enum 用字符串枚举、更推荐 as const 联合字面量、switch 加 never 穷尽检查、遍历用 Object.values、外部值赋值前边界校验。归根结底:用字符串枚举或 as const 联合字面量类型代替数字枚举(获得真正的类型安全和干净遍历);switch 加 never 穷尽检查防漏 case;表示"有限取值"要选真正能约束取值范围、且无运行时坑的方式。
第三件事:TypeScript 中其他"看起来安全实则不然"的地方
排查后我把 TS 中其他"看起来提供了类型安全、实则有漏洞"的地方也系统梳理了一遍。
TS 中其他"看起来安全实则不然"的地方
# 1. 数字枚举(本文): 接受任意number+反向映射。→ 字符串枚举/as const联合类型。
# 2. as 类型断言: 凭空断言, 骗过编译器但运行时可能不符。→ 边界用运行时校验。
# 3. any(同540篇): 关闭检查且传染。→ unknown+收窄。
# 4. 非空断言 x!: 断言非null, 错了运行时崩。→ 慎用, 先判null。
# 5. 类型只在编译期(同348篇): 运行时不校验。→ 边界运行时校验(zod等)。
# 6. 结构化类型混语义(同552篇): 结构相同则兼容。→ 品牌类型。
# 7. 索引访问默认不含undefined: arr[i]/obj[key] 可能是undefined却不在类型里。→ 开noUncheckedIndexedAccess。
# 8. 类型断言as绕过的对象字面量检查: 经断言/中转可绕过多余属性检查。→ 知其局限。
# 共同根源: TS的类型系统是"可以被绕过/有兼容性妥协"的——为了和JS兼容、为了灵活, 它在不少地方
# 做了"放松/妥协"(数字枚举兼容number、as可断言、any关检查); 这些地方"看起来有类型保护", 实则保护是打折的;
# 把它们当成"和真正的强类型一样可靠"来信赖, 就会在打折的地方踩坑。
# 核心: 了解TS类型系统"哪些地方的保护是打折/可绕过的"(数字枚举/as/any/!/索引访问); 选用真正能约束的写法
# (字符串枚举/as const/unknown+校验/严格配置); 别把"看起来有类型"当成"真的安全"。
排查让我把 TS 其他"看起来安全实则不然"的地方也梳理清了。一、数字枚举(本文)。二、as 类型断言。三、any。四、非空断言 !。五、类型只在编译期。六、结构化类型混语义。七、索引访问默认不含 undefined。八、as 绕过多余属性检查。它们的共同根源是:TS 的类型系统是"可以被绕过/有兼容性妥协"的——为了和 JS 兼容、为了灵活,它在不少地方做了放松/妥协(数字枚举兼容 number、as 可断言、any 关检查);这些地方"看起来有类型保护",实则保护是打折的;把它们当成和真正的强类型一样可靠来信赖,就会在打折的地方踩坑。核心是:了解 TS 类型系统"哪些地方的保护是打折/可绕过的"(数字枚举/as/any/!/索引访问);选用真正能约束的写法(字符串枚举/as const/unknown+校验/严格配置);别把"看起来有类型"当成"真的安全"。下面这张图,是这次数字枚举坑的成因与解法:
第四件事:三种"有限取值"表示方式对比表
这次踩坑后,我把数字枚举、字符串枚举、as const 联合字面量对比成一张表。
| 维度 | 数字枚举 | 字符串枚举 | as const 联合字面量 |
|---|---|---|---|
| 类型安全(拒非法值) | ✗ 接受任意 number | ✓ | ✓ |
| 反向映射污染遍历 | ✗ 有 | 无 | 无 |
| 运行时开销 | 有(生成对象) | 有(生成对象) | 小/可无 |
| 值的可读性 | 差(是数字) | 好(是字符串) | 好 |
| 推荐度 | 不推荐 | 可用 | ★ 推荐 |
这张表把三种方式钉清了。核心是:数字枚举给我的"类型安全"是打了折的、甚至是假的——它看起来把取值限定在了几个枚举成员,实际上因为兼容 number 而对任意数字敞开了大门;而字符串枚举和 as const 联合类型,才提供了"取值真的只能是这几个"的真正的类型安全。它给我的最大启发是:同一个"目标"(这里是"限定取值范围"),有多种"手段"可以实现,但这些手段达成目标的"程度/质量"可能天差地别——有的"看起来达成了"(数字枚举看起来限定了取值),有的"真正达成了"(as const 真的限定了);选错了手段(选了那个"看起来行其实打折"的),就会以为目标达成了、实则留了个洞。这给了我一种选型时的清醒:选择实现某个目标的手段时,不能只看"它名义上是干这个的"(枚举名义上就是限定取值的),而要深究"它到底在多大程度上、多可靠地达成了这个目标?有没有打折、有没有漏洞?"——对比几种手段在"达成目标的质量"上的差异, 选那个真正可靠的;"看穿手段'名义上做这个'和'真正做好这个'的差距、选真正可靠的手段",是做出正确技术选型的关键。认清数字枚举的类型安全是打折的、选手段要看真正达成目标的质量——是这个坑带给我的认知。
第五件事:这次事故暴露的"为兼容而做的妥协"
这次让我反思更深一层:数字枚举的两个坑,根上是 TS"为了和 JS 兼容、为了灵活"而做的妥协。我把"理想的强类型"和"TS 的妥协"对比成表。
| 维度 | 理想的强类型(我期望的) | TS 的现实(妥协) |
|---|---|---|
| 数字枚举取值 | 只能是定义的成员 | 接受任意 number(兼容) |
| 类型检查 | 不可绕过 | as/any 可绕过(灵活) |
| 运行时 | 类型存在 | 类型擦除(兼容 JS) |
| 设计取向 | 纯粹的安全 | 安全与 JS 兼容/灵活的平衡 |
| 代价 | — | 不少"看起来安全实则打折"的点 |
这张表道出了 TS 的本质处境。核心是:TS 的很多"坑"(数字枚举不安全、类型可擦除、as 能绕过),根上不是 bug,而是它"为了兼容已有的 JavaScript 生态、为了保持灵活"而刻意做出的设计妥协;它要在"纯粹的类型安全"和"能平滑地用在动态、灵活的 JS 世界里"之间找平衡,而平衡就意味着在某些点上牺牲一部分安全。它给我的深刻启发是:几乎每一个"建立在已有基础之上、要兼容历史"的技术(TS 之于 JS、各种向后兼容的新版本、各种为了平滑迁移的设计),都带着"为兼容而妥协"的胎记——它无法做到"纯粹、理想",而是充满了"为了不破坏旧的、为了让大家能用"的折中;理解一个技术的"怪异之处/不一致之处",往往要去理解它"背负了什么历史包袱、为兼容什么而妥协"。这给了我一种理解技术的更深视角:当一个技术有"看起来不合理、不一致、不纯粹"的设计时,不要简单地骂它"烂",而要试着去理解"它为什么这么设计?它在兼容什么、平衡什么、背负着什么历史约束?"——理解了这些妥协的来由, 你才能既用好它的灵活、又绕开它妥协带来的坑(如知道数字枚举为兼容 number 而不安全, 就主动避开它);"理解技术设计背后的兼容性妥协与历史约束",是从'抱怨它的坑'走向'理解并驾驭它'的成熟。认清 TS 的坑多源于为兼容 JS 的妥协、理解妥协来由才能既用好又避坑——是这个数字枚举坑带给我的认知。
第六件事:要表示"有限取值"时,我现在的自检习惯
现在每当我要在 TS 里表示一组"有限取值"(状态、类型、枚举),我都会先按这张图问自己:
这张图的精髓,是"别用数字枚举,优先 as const 联合字面量,加 never 穷尽检查"。只要类型联合字面量、要遍历as const 对象、一定用 enum字符串枚举,switch加 never 穷尽检查。这套习惯,让我从"表示状态就 enum 一把(默认数字)"变成了"优先 as const 联合字面量、绝不用数字枚举"——核心始终是:用字符串枚举或 as const 联合字面量类型代替数字枚举(真正类型安全、遍历干净),switch 加 never 穷尽检查防漏 case。
我立下的几条规矩
这场"数字 99 能进枚举、遍历多一倍"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- TS 数字枚举本质是 number,接受任意数字赋值,类型不安全。99 也能传进去。
- 数字枚举生成反向映射(名↔值双向),Object.keys/values 遍历会多出一倍、混乱。
- 别用数字枚举。要用 enum 就用字符串枚举(安全、遍历干净)。
- 更推荐 as const 对象 + 联合字面量类型。轻量、类型安全、无运行时坑。
- switch 处理有限取值时,用 never 做穷尽性检查,防漏 case/防加新成员忘处理。
- 外部(JSON/接口)的值赋给这些类型前,在边界做运行时校验。
- 选手段看"真正达成目标的质量",别把"看起来安全"当"真的安全"。
写在最后
回头看,这场由"用了数字枚举"引发的、类型不安全加遍历出错的事故,真正教给我的,远不止"改用字符串枚举/as const"这一个技巧。它让我对"一个工具'名字叫什么、看起来是干什么的', 和它'实际上、在细节里到底做到了什么程度', 可能有不小的差距; 而盲目信任'名字/表象', 就会在那个差距里栽跟头",有了一次刻骨的体会。我栽跟头,是因为我对"枚举(enum)"这个名字,有一种想当然的、未经验证的信任——"枚举嘛, 顾名思义, 就是把取值'枚举'清楚、限定成那么几个, 当然是类型安全的";我凭着'枚举'这个名字给我的印象, 就认定了它的行为, 从没去验证'它实际上真的把取值限死了吗?它遍历起来真的干净吗?';而当我真去看它的实际行为(接受任意数字、生成反向映射)时,才发现它的'实际'和它的'名字给我的印象'之间, 隔着我没料到的鸿沟。这让我领悟到一个关于"名与实"的深刻认知:我们对一个工具/概念的认知,常常始于、也止于它的"名字和表面印象",而没有深入到它"实际的、细节的行为";"名字听起来该是什么样" 和 "它实际是什么样" 之间, 往往有差距;而很多坑, 就藏在"我以为它是 A(因为它叫 A), 实际它是打了折的 A 或别的"这个名实不符的缝隙里。这给了我一种使用工具的根本审慎:对任何一个我要依赖的工具/特性,不要满足于"它叫什么、看起来该怎样"就信任它,而要主动去验证、去了解"它实际的行为、它的细节、它的边界到底是什么"——尤其当我要依赖它提供某种保证时(如枚举提供类型安全), 更要确认"它真的提供了吗?到什么程度?";"不轻信名字与表象、主动核实工具的实际行为",是避免在'名实不符'处踩坑、真正掌握一个工具的关键。认清名字给的印象和实际行为有差距、不轻信表象要主动核实实际行为——这,是我用一次数字枚举的事故,换来的、关于 TypeScript、也关于如何不被名与表象误导的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想用 enum 表示状态时,先停一下、改用字符串枚举或 as const 联合字面量,那我对着那能塞进 99 的枚举和翻倍的遍历排查的这段时间,就值了。
—— 别看了 · 2026