我用数字枚举定义状态,本以为类型很安全,结果一个根本不在枚举里的数字 99 被当成合法状态溜了进来,遍历枚举时还冒出一堆奇怪的数字键,我对着 TypeScript 数字枚举这两个坑排查了大半天的复盘
这是一个让我对 TypeScript 的 enum "从信任到警惕"的坑。它隐蔽在:enum 看起来是个"限定取值范围、保证类型安全"的好东西,我以为用了它,一个变量就只能是枚举里那几个值了;可它既没拦住非法的值溜进来,又在运行时生成了一堆我没料到的东西。
需求很常见:我用一个枚举来表示状态。我顺手用了数字枚举(最常见的写法):
enum Status {
Active, // 0
Inactive, // 1
Pending, // 2
}
// 默认数字枚举: Active=0, Inactive=1, Pending=2
function setStatus(s: Status) { /* 处理状态 */ }
// ★ 坑1: 数字枚举类型, 居然能接受任意数字!
setStatus(Status.Active); // ✓ 正常
setStatus(99); // 😱 居然不报错! 99根本不是任何一个Status, 却通过了类型检查!
const fromApi: Status = 99; // 😱 也不报错! 非法值就这么"合法地"流进来了
// ★ 坑2: 遍历枚举, 冒出一堆奇怪的键
for (const key in Status) {
console.log(key);
}
// 期望: Active, Inactive, Pending
// 实际: 0, 1, 2, Active, Inactive, Pending ← 多了一堆数字键?!
// (因为数字枚举编译后生成了"双向映射"的对象)
我盯着这两个现象,大跌眼镜。坑 1:我以为 Status 类型只能是 Active/Inactive/Pending,可 setStatus(99)、const x: Status = 99 这种传一个根本不在枚举里的数字,TypeScript 居然不报错!于是一个从外部来的非法状态值(99),就这么"合法地"通过了类型检查、流进了业务逻辑,埋下隐患。坑 2:我遍历这个枚举,本以为会得到 Active、Inactive、Pending 三个名字,结果多冒出了 0、1、2 这三个数字键!这个"数字枚举既不类型安全、又在运行时生成奇怪的东西"的现象,让我对 enum 这个看似简单的特性彻底改观。
第一件事:看清真相——数字枚举接受任意 number,且编译成双向映射的运行时对象
我去深入研究了 TypeScript 数字枚举的类型行为和它编译后的样子,才彻底明白这两个坑——第一,出于历史和兼容原因,TypeScript 的数字枚举类型,实际上可以接受任意 number(它把数字枚举看得比较"宽松",不严格限定在定义的那几个值);第二,数字枚举编译成 JS 后,会生成一个"双向映射"的对象(既有"名字→数字",也有"数字→名字"),所以遍历它会多出数字键,运行时也多了这些代码。
数字枚举两个坑的真相
# 坑1: 数字枚举类型可以接受【任意number】, 不类型安全!
# enum Status { Active, Inactive, Pending } // 0,1,2
# const x: Status = 99; // ✓ 不报错! 99不是任何Status, 却被接受了!
# - 原因: TypeScript对数字枚举的处理比较"宽松"(历史/位运算兼容等原因),
# 它允许把任意number赋给数字枚举类型, 不严格校验"必须是定义的那几个值"。
# - 后果: 外部传入的、计算出的非法数字(如99), 能"合法地"通过类型检查,
# 枚举"限定取值范围"的保护作用, 在这里【失效了】。
# 坑2: 数字枚举编译成JS后, 生成【双向映射(reverse mapping)】对象:
# enum Status { Active, Inactive }
# ↓ 编译成 JS 后大致是:
# var Status = {};
# Status[Status["Active"] = 0] = "Active"; // 既有 Active->0
# Status[Status["Inactive"] = 1] = "Inactive";// 又有 0->"Active"
# → 所以 Status 对象里既有 {Active:0, Inactive:1} 也有 {0:"Active", 1:"Inactive"}
# - 后果a: for...in 遍历会同时遍历到 数字键 和 名字键(多了一堆);
# - 后果b: enum 不是"纯类型", 它会生成运行时代码(对象), 有体积/运行时成本;
# (这也是为什么有 const enum——它会被内联、不生成对象, 但有别的限制。)
# 3. 对比: 【字符串枚举】没有这些坑:
# enum Status { Active = "active", Inactive = "inactive" }
# - 字符串枚举【不生成反向映射】(没有 "active"->Active 这种);
# - 类型上也更安全(不能把任意字符串赋给它);
# - 遍历干净, 值也更可读(日志里是"active"而非0)。
# 核心: TS数字枚举有两坑——类型上能接受任意number(不安全, 非法值能溜进来)、编译生成双向映射
# 对象(遍历多出数字键、有运行时代码); 字符串枚举或字面量联合类型更安全、更干净。
真相大白,我恍然大悟。第一个坑:出于历史和兼容(位运算等)原因,TypeScript 对数字枚举的处理比较"宽松"——它允许把任意 number 赋给数字枚举类型,不严格校验"必须是定义的那几个值";所以 const x: Status = 99 不报错,外部传入的非法数字(99)能"合法地"通过类型检查,枚举"限定取值范围"的保护在这里失效了。第二个坑:数字枚举编译成 JS 后,会生成一个"双向映射(reverse mapping)"的对象——既有 {Active:0, Inactive:1}(名字→数字),又有 {0:"Active", 1:"Inactive"}(数字→名字);所以 for...in 遍历会同时遍历到数字键和名字键(多了一堆),而且 enum 不是纯类型、会生成运行时代码(对象)、有体积成本(这也是 const enum 的由来)。对比之下,字符串枚举没有这些坑:enum Status { Active = "active" } 不生成反向映射、类型上更安全(不能把任意字符串赋给它)、遍历干净、值也更可读(日志里是 "active" 而非 0)。
第二件事:正解——用字符串枚举,或字面量联合类型 + as const 对象
搞懂了原理,正解就清晰了:优先用字符串枚举(更安全、无反向映射);或用"字面量联合类型 + as const 对象"这种更轻量、更类型安全、零运行时枚举对象的方案;外部数据仍要运行时校验。
// ====== 正解一: 用字符串枚举(比数字枚举安全干净) ======
enum Status {
Active = "active",
Inactive = "inactive",
Pending = "pending",
}
const x: Status = "active" as Status;
// const y: Status = "xxx"; // ✗ 字符串枚举不能随便赋任意字符串(比数字枚举严格)
// 优点: 无反向映射、遍历干净、值可读(日志是"active"); 类型也更安全。
// ====== 正解二(很多人更推荐): 字面量联合类型 + as const 对象 ======
// 用 as const 定义常量对象, 再从中推导出联合类型:
const Status = {
Active: "active",
Inactive: "inactive",
Pending: "pending",
} as const;
type Status = typeof Status[keyof typeof Status]; // "active" | "inactive" | "pending"
function setStatus(s: Status) { /* ... */ }
setStatus("active"); // ✓
// setStatus("xxx"); // ✗ 编译错误! 只能是那三个字面量之一, 真正类型安全!
// setStatus(99 as any); // 只有强行as any才能绕过(那是你自找的)
// 优点: 类型上是精确的字面量联合(最安全)、Status对象就是普通对象(无反向映射)、
// 遍历干净、可tree-shaking、值可读; 是现代TS里很流行的"枚举替代方案"。
// ====== 正解三: 外部数据仍要运行时校验(类型只是编译期) ======
// 不管用哪种, 从API/外部来的状态值, 运行时仍要校验它是不是合法状态:
function isStatus(v: unknown): v is Status {
return v === "active" || v === "inactive" || v === "pending";
}
// (因为类型是编译期的, 运行时的非法值要靠运行时校验拦, 见"数据泄漏/as断言"等篇)
// ====== 如果一定要用数字枚举 ======
// - 知道它能接受任意number、有反向映射的坑;
// - 遍历时过滤掉数字键: Object.keys(E).filter(k => isNaN(Number(k)))
// - 对外部数字仍要校验是否在枚举值范围内。
// 核心: 优先用字符串枚举或"字面量联合类型+as const对象"(类型安全、无反向映射、干净); 数字枚举
// 有"接受任意number+双向映射"的坑要少用; 外部数据无论如何都要运行时校验是否合法值。
修复的核心,是"用字符串枚举或字面量联合类型替代数字枚举,外部数据仍运行时校验"。正解一:用字符串枚举——enum Status { Active = "active" },无反向映射、遍历干净、值可读、类型也更安全(不能随便赋任意字符串)。正解二(很多人更推荐):字面量联合类型 + as const 对象——用 as const 定义常量对象,再 type Status = typeof Status[keyof typeof Status] 推导出精确的字面量联合类型("active"|"inactive"|"pending");setStatus("xxx") 会编译报错、真正类型安全,且 Status 就是普通对象(无反向映射)、可 tree-shaking,是现代 TS 流行的枚举替代方案。正解三:外部数据仍要运行时校验——类型只是编译期的,从 API 来的状态值运行时仍要用类型守卫校验是不是合法状态。如果一定要用数字枚举:知道它的坑,遍历时过滤数字键,对外部数字校验范围。归根结底:优先用字符串枚举或"字面量联合类型+as const 对象"(类型安全、无反向映射、干净);数字枚举有"接受任意 number+双向映射"的坑要少用;外部数据无论如何都要运行时校验。
第三件事:TypeScript 类型与运行时相关的其他坑
排查后我把 TypeScript "类型期望和运行时现实不符"的其他常见坑也系统梳理了一遍。
TS 类型与运行时的其他坑
# 1. 数字枚举接受任意number+反向映射(本文): 不安全+运行时对象。→ 字符串枚举/字面量联合。
# 2. 类型是编译期的, 运行时被擦除: 类型对≠运行时数据对(见as断言/数据校验篇)。
# 3. enum不是纯类型: 会生成运行时代码(对象), 有体积; 纯类型场景用union/as const。
# 4. readonly是编译期+浅层: 运行时仍可改、且只读外层(里层对象仍可变)。
# → 真不可变用Object.freeze(浅)/deepFreeze/不可变库。
# 5. interface/type的声明合并: interface会自动合并同名声明(可能意外); type不会。
# 6. 类型推断过宽/过窄: 比如let推断为宽类型、字面量没加as const推断为string。
# 7. 可选属性?和undefined: a?: T 意味着可能是undefined, 访问要判空。
# 8. 把类型当运行时校验: 以为标了类型运行时就安全(外部数据并不会自动符合类型)。
# 共同根源: TS的类型系统是【编译期】的、会被擦除; 而enum等少数特性又会生成运行时代码;
# 分不清"哪些是纯编译期的类型、哪些有运行时行为、类型保证到运行时还成不成立", 就会踩坑。
# 核心: 分清TS的编译期类型和运行时行为; 数字枚举/readonly等有"编译期保证≠运行时现实"的坑;
# 优先用类型安全又轻量的方案(字面量联合)、外部数据运行时校验、真不可变用freeze。
排查让我把 TS 类型与运行时的其他坑也梳理清了。一、数字枚举接受任意 number+反向映射(本文)。二、类型编译期擦除(类型对≠运行时数据对)。三、enum 不是纯类型(生成运行时对象,纯类型用 union/as const)。四、readonly 是编译期+浅层(运行时仍可改、只读外层,真不可变用 freeze)。五、interface 的声明合并(自动合并同名声明)。六、类型推断过宽/过窄。七、可选属性 ? 可能是 undefined。八、把类型当运行时校验。它们的共同根源是:TS 的类型系统是编译期的、会被擦除;而 enum 等少数特性又会生成运行时代码;分不清"哪些是纯编译期类型、哪些有运行时行为、类型保证到运行时还成不成立",就会踩坑。核心是:分清 TS 的编译期类型和运行时行为;优先用类型安全又轻量的方案(字面量联合)、外部数据运行时校验、真不可变用 freeze。下面这张图,是这次数字枚举的成因与解法:
第四件事:几种"枚举"方案对照表
这次踩坑后,我把 TS 里几种表示"固定取值集合"的方案整理成一张表。
| 方案 | 类型安全 | 运行时 | 说明 |
|---|---|---|---|
| 数字枚举 | ✗ 接受任意number | 有对象+反向映射 | 坑多, 少用 |
| 字符串枚举 | ✓ 较安全 | 有对象, 无反向映射 | 比数字枚举好 |
| const enum | 同枚举 | 内联, 不生成对象 | 省体积, 但有限制/不能遍历 |
| 字面量联合类型 | ✓ 最安全 | 纯类型, 零运行时 | 类型层面最干净 |
| as const 对象 + 联合 | ✓ 最安全 | 普通对象, 可遍历 | 既安全又能拿到值集合, 推荐 |
这张表把"枚举方案"的取舍钉清了。核心是:数字枚举类型最不安全(接受任意 number)、运行时还有反向映射;字符串枚举好一些;而"字面量联合类型"和"as const 对象+联合"既类型安全又干净(后者还能在运行时拿到值的集合),是现代 TS 里更受推崇的方案。它给我的最大启发是:同一个需求("表示一组固定取值"),TypeScript 提供了好几种方案,而语言/社区推荐的"最佳方案",会随着语言演进而变化——早期大家都用 enum,后来发现它的种种坑,社区逐渐转向了"字面量联合类型 / as const 对象"这种更轻量、更安全的方案。这让我意识到一个学习态度:对一个常见需求,不要满足于"我会用某一种老办法"(如 enum),而要了解这个领域里有哪些方案、它们各自的优缺点、以及社区当前更推荐哪种;技术在演进,曾经的"标准做法"可能已经有了更好的替代,跟上这种演进,能让你写出更现代、更健壮的代码。了解一个需求的多种方案及其演进、采用社区当前更优的实践(字面量联合替代数字枚举)——是这个枚举坑教给我的进阶意识。
第五件事:为什么"看起来安全"的特性反而最危险
这次让我反思了一个有点反直觉的点:数字枚举的危险,恰恰在于它"看起来很安全"。
| 方面 | 说明 |
|---|---|
| 名字暗示安全 | "枚举"听起来就是"限定在这几个值", 让人放心 |
| 常规用法都正常 | 用 Status.Active 这种规范写法时一切正常 |
| 给人虚假的保护感 | 用了枚举, 就以为取值范围被严格限制了 |
| 边界处才暴露 | 只有传非法数字、遍历时, 不安全才显现 |
| 比"明显不安全"更坑 | 明知不安全会防, 自以为安全反而不设防 |
这张表道出了数字枚举"看似安全实则不"的危险。核心是:数字枚举的危险,恰恰在于它的名字和常规用法都给人一种"很安全、取值被严格限定"的错觉;正因为我"以为它安全",我就放松了警惕、不再去防范非法值(没做运行时校验);而它实际并不安全——这种"自以为有保护、其实没有"的状态,比"明知没保护"更危险。它给我的深刻启发是:一个"看起来提供了某种保证、实际却没完全提供"的东西,比一个"明确告诉你它不提供保证"的东西更危险;因为后者会让你主动去防范,而前者会用"虚假的安全感"麻痹你的警惕、让你疏于防范,最终在你最没防备时出问题。这让我形成一个警觉:对那些"名字/外表暗示了某种保证"的特性(枚举=安全取值、readonly=不可变、private=外部访问不到),要主动去确认"它到底提供了多强的保证、有没有我以为的那么强",而不是仅凭名字和直觉就信任它;很多坑,正是源于"对一个并没有那么强保证的东西,给予了过强的信任"。警惕"看似安全"的虚假保护感、主动确认特性的真实保证强度——是这个数字枚举坑,带给我的关于"如何不被表象误导"的清醒。
第六件事:要表示一组固定取值时,我现在的判断习惯
现在每当我要表示"一组固定的取值"(状态、类型、选项),我都会按这张图先想清楚:
这张图的精髓,是"优先用字面量联合/as const 对象,要 enum 也用字符串枚举,外部数据运行时校验"。首选字面量联合类型或 as const 对象+联合(需要遍历值集合用后者、只要类型约束用前者);团队习惯用 enum 的话尽量用字符串枚举别用数字枚举,并知道它仍有运行时对象;外部数据无论如何运行时校验是否合法值。这套习惯,让我表示固定取值时,从"随手数字 enum"变成了"优先字面量联合、要 enum 也用字符串"——核心始终是:数字枚举不安全且有运行时坑,优先字面量联合/as const,外部数据运行时校验。
我立下的几条规矩
这场"数字枚举不安全"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- 数字枚举类型能接受任意 number。非法值能溜进来,不类型安全。
- 数字枚举编译成双向映射对象。遍历多出数字键、有运行时代码。
- 优先用字面量联合类型 / as const 对象。类型安全、轻量、干净。
- 要用 enum 就用字符串枚举。比数字枚举安全、无反向映射、值可读。
- 外部数据无论如何要运行时校验。类型是编译期的,拦不住运行时非法值。
- 遍历数字枚举要过滤数字键。filter(k => isNaN(Number(k)))。
- 警惕"看似安全"的特性。主动确认它真实的保证强度。
附:一段亲眼看清数字枚举两个坑的实验
口说无凭。下面这段代码,把数字枚举的"接受任意数字"和"反向映射"两个坑,以及字符串枚举/字面量联合的对比,一次演示清楚:
enum NumStatus { Active, Inactive, Pending } // 数字枚举 0,1,2
enum StrStatus { Active = "active", Inactive = "inactive" } // 字符串枚举
console.log("=== 1. 数字枚举接受任意number(不安全) ===");
const bad: NumStatus = 99; // ✓ 编译通过! 99根本不是合法状态
console.log(" bad =", bad); // 99 —— 非法值就这么进来了
console.log("\n=== 2. 数字枚举的反向映射(遍历多出数字键) ===");
console.log(" Object.keys(NumStatus):", Object.keys(NumStatus));
// ["0","1","2","Active","Inactive","Pending"] ← 多了数字键!
console.log(" NumStatus[0]:", NumStatus[0]); // "Active" ← 反向映射: 数字->名字
console.log(" 只取名字键:",
Object.keys(NumStatus).filter(k => isNaN(Number(k)))); // ["Active","Inactive","Pending"]
console.log("\n=== 3. 字符串枚举没有反向映射(干净) ===");
console.log(" Object.keys(StrStatus):", Object.keys(StrStatus));
// ["Active","Inactive"] ← 干净, 没有反向映射的多余键
console.log("\n=== 4. 字面量联合类型(类型最安全, 零运行时) ===");
type LitStatus = "active" | "inactive" | "pending";
const ok: LitStatus = "active"; // ✓
// const no: LitStatus = "xxx"; // ✗ 编译错误! 只能是那三个之一
// const no2: LitStatus = 99 as any; // 只有强行as any能绕过
// (LitStatus是纯类型, 编译后完全消失, 零运行时成本)
// 核心: 跑一遍, 亲眼看到数字枚举能接受99、Object.keys多出数字键(反向映射)、
// 而字符串枚举遍历干净、字面量联合类型上根本不接受非法值——两个坑和替代方案一目了然。
这段实验代码,是我这次踩坑后写下的"枚举坑显形器"。它用四组对比,把数字枚举的两个坑摊在你眼前:第 1 段让你看到 const bad: NumStatus = 99 居然编译通过(非法值溜进来);第 2 段用 Object.keys 让你看到数字枚举遍历出来多了 "0"、"1"、"2" 这些数字键(反向映射的产物),还演示了 NumStatus[0] 能反查出 "Active";第 3 段对比字符串枚举的 Object.keys 干干净净;第 4 段则展示字面量联合类型从类型上就拒绝非法值、且零运行时。这正是我想用这段代码,留给每个 TS 开发者的核心方法:当你对一个特性(尤其涉及"它编译后变成什么、运行时行为如何"的)拿不准时,写几行代码把它的运行时产物直接打印出来(用 Object.keys、console.log 看它真实的样子)、并用边界值(如非法的 99)去试探它的类型检查到底严不严。因为一个特性"编译后到底生成了什么、运行时到底长什么样、它的类型检查到底拦不拦得住非法值",光看文档或凭想象常常不准;而把它的运行时产物打印出来、用边界值去试探,能让它的真实行为无所遁形;"打印产物 + 边界试探",是看清一个特性(尤其是有编译期/运行时双重身份的特性,如 enum)真实面目最直接的手段。用"打印运行时产物 + 边界值试探类型检查"看清特性的真实行为——这份习惯,是我避免被特性的名字和文档表象误导、确认其真相最可靠的法门。
写在最后
回头看,这场由"数字枚举"引发的、非法值溜进来的事故,真正教给我的,远不止"用字符串枚举或字面量联合"这一个技巧。它让我对"不能仅凭一个特性的名字和外表,就推断它的行为",有了一次深刻的体会。我栽跟头,是因为我被 "enum(枚举)" 这个名字误导了。"枚举"这个词,在我的认知里(以及在很多其他语言里),天然意味着"一个被严格限定在若干个具名值之内的类型"——所以我想当然地以为 TypeScript 的数字枚举也是如此,会帮我把取值严格限制住。可我没有去确认 TypeScript 的数字枚举实际是怎么定义、怎么行为的,就凭着"枚举"这个名字给我的印象去信任它了——而它实际的行为(接受任意 number、生成双向映射),和我从名字推断的大相径庭。这让我领悟到一个深刻的认知:一个特性/概念的"名字",承载着我们对它的先验期待(尤其当这个名字在别处有约定俗成的含义时);但这个"名字带来的期待",和它在这个具体语言/工具里的实际行为,未必一致;仅凭名字去推断、信任一个特性的行为,是危险的——你信任的是"你以为它该是的样子",而非"它实际的样子"。这其实是一个反复出现的教训(就像 JS 的 sort 不按数值排、Go 的 ConcurrentModification 在单线程也报):对任何一个特性,尤其是那些"名字听起来很熟悉、似乎不言自明"的,都要带着一份审慎,去确认它在当前语境下的真实定义和行为,而不是用名字唤起的、来自别处的直觉去想当然;"名副其实"是一种美好的期待,但工程上,我们必须验证"名是否真的副其实",而非假设它一定如此。不被特性的名字和外表误导、主动确认它在当前语境下的真实行为——这,是我用一次数字枚举的事故,换来的、关于 TypeScript、也关于如何严谨地认识任何技术特性的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想用数字 enum 时,先想起"它其实能接受任意数字哦"、转而用字面量联合类型,那我对着那个溜进来的非法值 99 排查的这大半天,就值了。
—— 别看了 · 2026