我遍历一个数字枚举想拿到所有选项,结果拿到了双倍的条目、数字和名字混在一起,我盯着这串诡异的结果查了半天才搞懂枚举反向映射的深度复盘
这是一个让我对 TypeScript "枚举编译后长什么样"刻骨铭心的故事。我定义了一个数字枚举,用来表示状态:enum Status { Active, Inactive, Deleted }。然后,我想遍历这个枚举,拿到它所有的选项,做个下拉框之类的。我很自然地,用了 Object.keys(Status) 或 Object.values(Status) 去遍历它。在我朴素的认知里,这个枚举有 3 个选项,那遍历出来,不就该是 3 个嘛。
可结果,把我整懵了:我遍历这个只有 3 个选项的枚举,拿到的,竟然是 6 个条目!而且,这 6 个,是数字和名字混在一起的——Object.keys(Status) 返回的,竟是 ["0", "1", "2", "Active", "Inactive", "Deleted"]!前面三个,是数字索引的字符串,后面三个,才是我定义的选项名字。我那个本该是 3 项的下拉框,变成了诡异的 6 项,一半是名字、一半是数字。我当时百思不得其解:我明明只定义了 3 个枚举值啊!这多出来的 3 个数字,是从哪冒出来的?我反复确认枚举的定义,确实只有 3 个。直到我去看这个枚举编译成 JavaScript 之后的样子,才恍然大悟,补上了关于 TS 枚举最重要的一课:原来,TypeScript 的数字枚举,在编译成 JavaScript 后,会变成一个"双向映射(reverse mapping)"的对象!也就是说,它不只存了"名字 → 数字"(Active: 0),还反过来存了"数字 → 名字"(0: "Active")!所以,我那个 enum Status { Active, Inactive, Deleted },编译后,实际上是一个有 6 个键值对的对象:{ 0:"Active", 1:"Inactive", 2:"Deleted", Active:0, Inactive:1, Deleted:2 }。于是,我用 Object.keys 去遍历它,自然就拿到了全部 6 个键——3 个数字字符串("0"/"1"/"2"),加上 3 个名字。TypeScript 之所以这么设计,是为了一个便利:让你既能用 Status.Active 拿到数字 0、也能用 Status[0] 反查出名字 "Active"。可这个为"双向查找"而生的便利,却在我"遍历枚举"的时候,变成了一个意料之外的坑——我以为我在遍历一个有 3 个成员的"枚举",实际上,我在遍历一个有 6 个键的、双向映射的"普通对象"。我把"枚举"这个语法概念,和它编译后那个双向映射的 JS 对象现实,想当然地划上了等号。
故障现场:数字枚举编译成了双向映射的对象
我把这个"双倍条目"的现场,用代码摊开给你看:
// ✗ 灾难: 遍历数字枚举, 拿到双倍的条目
enum Status { Active, Inactive, Deleted } // 我以为就 3 个选项
console.log(Object.keys(Status));
// ✗ ["0", "1", "2", "Active", "Inactive", "Deleted"] —— 6 个! 数字+名字混在一起
console.log(Object.values(Status));
// ✗ ["Active", "Inactive", "Deleted", 0, 1, 2] —— 也是 6 个!
// 想做个下拉框, 遍历它 → 出来 6 项, 一半是数字, 一半是名字, 全乱了。
// 为什么? 因为数字枚举编译成 JS 后, 是个"双向映射"的对象:
// 编译后的 JS 大致是:
// var Status;
// (function (Status) {
// Status[Status["Active"] = 0] = "Active"; // 同时存 Active:0 和 0:"Active"
// Status[Status["Inactive"] = 1] = "Inactive"; // 同时存 Inactive:1 和 1:"Inactive"
// Status[Status["Deleted"] = 2] = "Deleted"; // 同时存 Deleted:2 和 2:"Deleted"
// })(Status || (Status = {}));
// → 最终 Status = { 0:"Active",1:"Inactive",2:"Deleted",
// Active:0,Inactive:1,Deleted:2 } —— 6 个键值对!
// 为什么这么设计? 为了"双向查找"的便利:
// Status.Active → 0 (名字 → 数字)
// Status[0] → "Active"(数字 → 名字, 反向映射)
// ↑ 这个便利, 却让"遍历枚举"拿到了双倍条目。
// 根因: 把"枚举"这个语法概念, 当成了"只有N个成员的纯净集合";
// 而它编译后, 其实是个"有 2N 个键的双向映射对象"。
// 遍历它(Object.keys/values), 就拿到了正反两份。
看着这段代码和它编译后的样子,我才算真正理解了这个"双倍条目"的根源。问题的核心,是我把 TypeScript 的"枚举",当成了一个纯粹的、只有 N 个成员的语法概念;可它在编译成 JavaScript 之后,真实的身份,是一个有 2N 个键的"双向映射(reverse mapping)"对象。具体来说:TypeScript 的数字枚举,编译后,不只存了"名字 → 数字"的正向映射(Active: 0),还额外反过来存了"数字 → 名字"的反向映射(0: "Active")。所以,我那个看起来只有 3 个成员的 enum Status,编译后,实际上是一个有 6 个键值对的对象:{ 0:"Active", 1:"Inactive", 2:"Deleted", Active:0, Inactive:1, Deleted:2 }。于是,当我用 Object.keys(Status) 去遍历它时,它忠实地返回了这个对象的全部 6 个键——3 个数字字符串("0"/"1"/"2"),加上 3 个名字("Active"/"Inactive"/"Deleted")。这就是我那个本该 3 项的下拉框,变成诡异的 6 项的原因。而 TypeScript 之所以这么设计,是为了一个便利:让你既能用 Status.Active 拿到数字 0(正向),也能用 Status[0] 反查出名字 "Active"(反向)——这个"双向查找"的能力,在某些场景下确实有用(比如根据存到数据库的数字,反查出它的名字)。可这个为"双向查找"而生的便利,却在我"遍历枚举"的时候,变成了一个意料之外的坑:我以为我在遍历一个有 3 个成员的"枚举",实际上,我在遍历的,是一个有 6 个键的、双向映射的"普通 JS 对象"。归根结底:我犯的错,是把"枚举"这个 TypeScript 的语法糖概念,和它编译成 JS 后那个双向映射对象的现实,想当然地划上了等号;我只看到了"枚举"这个抽象的表象,却没看到它落地成 JavaScript 之后,那个会"正反各存一份"的、真实的样子。
第一件事:搞懂数字枚举编译成"双向映射对象"
定位到根源,我必须把"数字枚举编译后的真实样子"彻底搞清楚:
数字枚举编译后 = 双向映射(reverse mapping)的对象
# 数字枚举:
enum Status { Active, Inactive, Deleted }
# 编译成 JS 后, Status 是这样一个对象:
# {
# 0: "Active", 1: "Inactive", 2: "Deleted", ← 反向: 数字 → 名字
# Active: 0, Inactive: 1, Deleted: 2 ← 正向: 名字 → 数字
# }
# → 一个 N 成员的数字枚举, 编译后有 2N 个键!
# 为什么有反向映射? 为了能"双向查":
# Status.Active → 0 (正向)
# Status[0] → "Active" (反向, 方便"拿到数字, 反查名字")
# 后果(遍历时的坑):
# Object.keys(Status) → ["0","1","2","Active","Inactive","Deleted"] (6个)
# Object.values(Status) → ["Active","Inactive","Deleted",0,1,2] (6个)
# for...in 也一样, 拿到正反两份。
# → 直接遍历数字枚举, 会拿到"双倍"的、数字名字混杂的条目!
# 重要区别: "字符串枚举"没有反向映射!
enum Color { Red = "RED", Green = "GREEN" }
# 编译后: { Red: "RED", Green: "GREEN" } ← 只有正向, 没有反向!
# Object.keys(Color) → ["Red", "Green"] (干净, 2个)
# → 因为字符串值没法安全地做反向键, TS 就不生成反向映射。
# 所以:
# - 数字枚举: 有反向映射, 遍历有"双倍"坑。
# - 字符串枚举: 无反向映射, 遍历干净。
# 核心: 数字枚举编译后是"双向映射对象"(2N个键)。
# 遍历它要小心"双倍条目"; 要干净遍历, 用字符串枚举, 或过滤, 或用别的方式。
原理终于刻进脑子里了。一个 N 个成员的数字枚举,编译成 JavaScript 后,是一个有 2N 个键的"双向映射"对象:它既存了"数字 → 名字"的反向映射(0: "Active"),又存了"名字 → 数字"的正向映射(Active: 0)。它之所以要存这份反向映射,是为了让你能"双向查":Status.Active 拿数字(正向)、Status[0] 反查名字(反向)。而它带来的后果,就是遍历时的那个坑:Object.keys(Status) 会返回全部 6 个键(["0","1","2","Active","Inactive","Deleted"])、Object.values 同理、for...in 也一样——直接遍历数字枚举,会拿到"双倍"的、数字名字混杂的条目。而这里有一个极其重要的区别:"字符串枚举"是没有反向映射的!enum Color { Red = "RED" } 编译后,只是 { Red: "RED" }(只有正向),所以 Object.keys(Color) 返回的是干净的 ["Red"]——因为字符串值没法安全地做反向键,TS 就不为字符串枚举生成反向映射。所以,一个清晰的对比是:数字枚举,有反向映射,遍历时有"双倍"坑;字符串枚举,无反向映射,遍历是干净的。由此,我得出了那个本该一开始就掌握的结论:数字枚举,编译后是一个"双向映射对象"(2N 个键);遍历它,要小心"双倍条目";如果你想要干净地遍历枚举,可以用字符串枚举、或者过滤掉数字键、或者干脆用别的方式(如 as const 对象)来表达枚举——这,是我用一个"双倍下拉框",补上的、关于 TS 枚举最关键的一课。
第二件事:正解——用字符串枚举,或干脆用 as const 对象
搞懂了根因——"数字枚举有反向映射、遍历出双倍"——正解就清晰了:要干净地遍历,优先用字符串枚举(它没有反向映射);或者更现代地,干脆用 as const 对象来代替枚举(很多场景下它更简单、更安全)。如果非要遍历数字枚举,就过滤掉那些数字键。
// 正解1: 用字符串枚举(没有反向映射, 遍历干净)
enum Status { Active = "ACTIVE", Inactive = "INACTIVE", Deleted = "DELETED" }
console.log(Object.keys(Status)); // ✓ ["Active","Inactive","Deleted"] 干净!
console.log(Object.values(Status)); // ✓ ["ACTIVE","INACTIVE","DELETED"]
// 额外好处: 存到 DB/日志里是有意义的字符串("ACTIVE"), 而不是含义不明的数字 0。
// 正解2(更现代/推荐): 用 as const 对象, 代替 enum
const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE",
Deleted: "DELETED",
} as const;
type Status = typeof Status[keyof typeof Status]; // 联合类型 "ACTIVE"|"INACTIVE"|...
console.log(Object.values(Status)); // ✓ ["ACTIVE","INACTIVE","DELETED"] 干净!
// 优点: 就是个普通对象, 行为可预测(没有 enum 的编译魔法/反向映射),
// 遍历干净, 也不会有 enum 的一堆坑。很多团队/Lint 推荐用它替代 enum。
// 正解3: 非要遍历数字枚举 → 过滤掉数字键
enum Num { A, B, C }
const keys = Object.keys(Num).filter(k => isNaN(Number(k))); // 过滤掉 "0","1","2"
console.log(keys); // ✓ ["A","B","C"]
// 或拿值: Object.values(Num).filter(v => typeof v === "number") → [0,1,2]
// 选择建议:
// - 需要遍历/序列化/可读性 → 字符串枚举 或 as const 对象(推荐后者)。
// - 就要数字、且要双向查(数字反查名字) → 数字枚举(但遍历记得过滤)。
// - 拿不准 → as const 对象, 最简单、最可预测、坑最少。
// 核心: 数字枚举遍历有双倍坑; 用字符串枚举/as const 对象避开它。
// 优先选"行为可预测"的方式来表达一组常量。
这套正解,核心是用"行为可预测、没有反向映射"的方式,来表达一组常量。正解1(字符串枚举):把枚举的值,显式地写成字符串(Active = "ACTIVE")——字符串枚举没有反向映射,所以 Object.keys/Object.values 遍历出来是干净的;而且它还有个额外的好处:存到数据库、打到日志里时,是有意义的字符串("ACTIVE"),而不是含义不明的数字 0(数字枚举存的是 0,过段时间你都不知道 0 是啥)。正解2(as const 对象,更现代、更推荐):干脆不用 enum,而用一个 as const 的普通对象来表达枚举,再用 typeof + keyof 推导出对应的联合类型;它的优点是——它就是一个普通对象,行为完全可预测(没有 enum 那些编译期的魔法和反向映射),遍历干净,也不会有 enum 的一堆其它坑;很多团队和 Lint 规则,都推荐用它来替代 enum。正解3(非要遍历数字枚举 → 过滤):如果你就是要用数字枚举,那遍历时,记得过滤掉那些数字键(Object.keys(Num).filter(k => isNaN(Number(k)))),或者拿值时过滤出数字。而选择建议是:需要遍历、序列化、可读性的,用字符串枚举或 as const 对象(推荐后者);就要数字、且要双向查的,才用数字枚举(但遍历记得过滤);拿不准时,用 as const 对象——它最简单、最可预测、坑最少。归根结底:数字枚举遍历有"双倍"坑,用字符串枚举或 as const 对象就能避开它;优先选择"行为可预测"的方式,来表达一组常量。我那次的错误,正是用了行为有"魔法"的数字枚举、又直接遍历它;而正解,就是换成一个行为可预测、没有反向映射的表达方式。
下面这张图,对比了"数字枚举"和"字符串枚举/as const"两条路径:
这张图的对比很清楚:左边红色那条,用数字枚举,编译成有 2N 个键的双向映射对象,Object.keys 拿到数字+名字的双倍条目,遍历/下拉框就乱了;右边绿色那条,用字符串枚举或 as const 对象,没有反向映射、就是个普通对象,遍历干净(N 个)、而且值还可读。两条路的根本分野,在于你用的,是一个有"编译魔法"的数字枚举,还是一个行为可预测的表达方式。
第三件事:枚举的其它坑
填平了反向映射这个坑,我系统排查了一遍 TS 枚举的其它常见坑:
// TypeScript 枚举的其它坑:
// 1. 数字枚举反向映射(本文): 遍历出双倍条目。
// 2. 数字枚举的"值"不安全: 任意数字都能赋给它(没穷尽检查保护)
enum Dir { Up, Down }
const d: Dir = 99; // ✗ 不报错! 99 也被当成合法的 Dir(数字枚举太宽松)
// → 字符串枚举/联合类型更安全, 只能是定义的那几个值。
// 3. const enum: 编译后被"内联", 不生成对象(没运行时对象!)
const enum E { A, B }
// console.log(E); // ✗ 报错/没东西! const enum 运行时不存在, 不能遍历/反射。
// (它把 E.A 直接替换成 0, 性能好, 但有跨模块/隔离编译的坑, 慎用)
// 4. enum 既是"类型"又是"值", 容易混淆作用域。
// 5. 数字枚举存 DB 的坑: 存的是数字(0,1,2)。
// 如果以后调整了枚举顺序/插入了新成员, 数字含义就错位了!→ 历史数据全乱。
// → 存 DB 用"字符串枚举"或显式固定的数字, 别依赖"自动递增的隐式数字"。
// 6. 跨项目/序列化: 数字枚举的数字, 脱离代码就没有含义(0 是啥?)。
// 字符串枚举("ACTIVE")自解释, 更适合序列化/接口/存储。
// 共同点: 很多坑源于"数字枚举的隐式数字 + 编译魔法(反向映射/内联)"。
// 原则: 优先用字符串枚举 或 as const 对象; 数字枚举仅在确有需要时用,
// 且用时清楚它的反向映射、值不安全、存DB错位等坑。
这一排查,让我对 TS 枚举的"雷区",有了全面的认识。除了反向映射(本文),枚举还有几个坑:数字枚举的值不安全(const d: Dir = 99 竟然不报错!任意数字都能赋给数字枚举,它太宽松了——而字符串枚举/联合类型更安全,只能是定义的那几个值);const enum 的坑(它编译后会被"内联"、不生成运行时对象,所以你不能在运行时遍历或反射它,而且有跨模块、隔离编译的坑,要慎用);枚举既是类型又是值(容易混淆作用域);数字枚举存数据库的坑(存的是数字 0/1/2,一旦以后调整了枚举顺序、或中间插入了新成员,数字的含义就错位了,历史数据全乱——存 DB 要用字符串枚举、或显式固定的数字,别依赖那个"自动递增的隐式数字");跨项目/序列化的坑(数字枚举的数字,脱离了代码就没有含义了,"0 是啥?"——而字符串枚举 "ACTIVE" 是自解释的,更适合序列化、接口、存储)。这些坑的共同点,大多源于"数字枚举的隐式数字 + 编译期的魔法(反向映射/内联)"。所以,核心原则就是:优先用字符串枚举、或 as const 对象;数字枚举仅在确有需要(比如真的要双向查、或对接已有的数字协议)时才用,且用的时候,要清楚它的反向映射、值不安全、存 DB 会错位等一系列坑。把这些都搞清楚,枚举,才能用得安心。
第四件事:enum vs 联合类型 vs as const,到底该用哪个
这次踩坑,逼我把"表达一组常量"的几种方式,系统地对比了一遍,搞清楚了到底该用哪个:
// 表达"一组常量/选项"的三种方式, 对比:
// 方式1: 字面量联合类型(最轻量)
type Status = "active" | "inactive" | "deleted";
const s: Status = "active"; // ✓ 类型安全, 只能是这几个值
// 优点: 零运行时开销(纯类型, 编译后消失)、简单、类型安全。
// 缺点: 没有"运行时的值集合"可遍历(它编译后不存在)。
// 方式2: as const 对象(推荐, 兼顾类型和运行时)
const Status = { Active: "active", Inactive: "inactive" } as const;
type Status = typeof Status[keyof typeof Status];
// 优点: 既有"运行时对象"(可遍历 Object.values, 干净)、又有类型安全。
// 行为可预测(就是普通对象, 没 enum 的魔法)。坑最少。
// 方式3: enum(TS 特有的语法)
enum Status { Active = "active", Inactive = "inactive" }
// 优点: 语法直观、自带命名空间。
// 缺点: 数字枚举有反向映射/值不安全; const enum 有内联坑; 是 TS 特有,
// 编译有"魔法", 行为不如普通对象可预测。
// 怎么选?
// - 只需要"类型约束"(不需要运行时遍历值) → 字面量联合类型(最轻)。
// - 既要类型、又要运行时遍历值/可读字符串 → as const 对象(推荐)。
// - 团队习惯用 enum / 要双向查 → 用 enum, 但优先字符串枚举, 慎用数字/const enum。
// 业界趋势: 很多团队和规范(如一些 lint 规则)倾向于
// "用 字面量联合类型 + as const 对象, 替代 enum"——
// 因为它们更贴近 JS 原生、行为更可预测、坑更少。
// 核心: 表达一组常量, 优先考虑"字面量联合 / as const 对象";
// enum 不是唯一选择, 且数字 enum 的"魔法"会带来本文这类坑。
这一对比,让我对"表达一组常量"这件事,有了清晰的选型认识。方式1(字面量联合类型,最轻量):type Status = "active" | "inactive"——它是纯类型,零运行时开销(编译后就消失了)、简单、类型安全;但缺点是,它没有一个运行时的值集合可供遍历(因为它编译后根本不存在)。方式2(as const 对象,推荐):用一个 as const 对象,再推导出类型——它兼顾了两者:既有一个运行时的对象(可以干净地 Object.values 遍历)、又有类型安全;而且它行为完全可预测(就是个普通对象,没有 enum 的魔法),坑最少。方式3(enum):它语法直观、自带命名空间;但缺点是,数字枚举有反向映射和值不安全的问题、const enum 有内联的坑,而且它是 TS 特有的、编译有"魔法",行为不如普通对象可预测。那么怎么选?——只需要"类型约束"(不需要运行时遍历值)的,用字面量联合类型(最轻);既要类型、又要运行时遍历值或可读字符串的,用 as const 对象(推荐);团队习惯用 enum、或确实要双向查的,才用 enum,但要优先字符串枚举、慎用数字枚举和 const enum。而且,有一个业界趋势值得了解:很多团队和代码规范(如一些 lint 规则),都倾向于"用字面量联合类型 + as const 对象,来替代 enum"——因为它们更贴近 JavaScript 原生、行为更可预测、坑更少。归根结底:表达一组常量,优先考虑"字面量联合类型 / as const 对象";enum 不是唯一的选择,而且数字 enum 的那些"魔法",会带来像本文这样意料之外的坑。把这三种方式的对比,整理成一张表:
| 方式 | 运行时可遍历 | 类型安全 | 坑/适用 |
|---|---|---|---|
| 字面量联合类型 | 否(编译后消失) | 是 | 最轻,只需类型约束时 |
| as const 对象 | 是(干净) | 是 | 推荐,坑最少,要遍历值时 |
| 字符串枚举 | 是(干净) | 是 | 可用,值可读 |
| 数字枚举 | 是(有双倍坑) | 较弱(值不安全) | 慎用,要双向查时 |
第五件事:用高层语法糖,要看穿它编译后的样子
这次踩坑,在认知层面给了我最大的纠偏——它让我警惕"只看语法糖、不看它编译成什么"。我把这层反思,沉淀了下来:
认知纠偏: 用高层语法糖, 要看穿它"编译/降级后"的真实样子
# 我的误解(错误的):
# 我把 enum 当成一个"纯粹的、N 个成员的枚举概念", 凭这个直觉去遍历它。
# → 我只看了 enum 的"语法表象", 没看它"编译成 JS 后"是个双向映射对象。
# 真相: 高层语法糖, 最终都要"编译/降级"成底层代码, 而那才是它真实的行为
# - TS enum → 编译成 JS 的双向映射对象(本文)。
# - async/await → 编译成状态机/Promise 链。
# - JSX → 编译成 createElement 调用。
# - 解构/扩展/可选链 → 编译成一堆判断和赋值。
# → 语法糖让你"写得爽", 但它"实际是什么、怎么运行的", 在编译产物里。
# 不懂编译产物的代价:
# - 行为意外(本文: 遍历出双倍)。
# - 性能意外(某语法糖编译后开销很大)。
# - 调试困难(报错指向编译后的代码, 看不懂)。
# 正确的习惯:
# 1. 用一个高层特性(尤其有"编译魔法"的), 了解它"编译成什么"。
# 2. 行为反直觉时, 去看编译产物(TS Playground 能看 JS 输出)——真相在那。
# 3. 优先选"编译后行为可预测、贴近底层"的写法(如 as const vs enum)。
# 核心: 语法糖是"给人看的便利", 编译产物是"机器跑的真相"。
# 理解一个语法糖编译后的样子, 才能预判它的行为、避开它的魔法带来的坑。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我把 enum,当成了一个"纯粹的、N 个成员的枚举概念",凭着这个直觉去遍历它;我只看了 enum 的"语法表象",却没看它"编译成 JS 之后"那个双向映射对象的真实样子。可真相是:高层的语法糖,最终都要"编译/降级"成底层的代码,而那,才是它真实的行为。在 TypeScript/JavaScript 里,这样的例子比比皆是:TS 的 enum 编译成 JS 的双向映射对象(本文);async/await 编译成状态机/Promise 链;JSX 编译成 createElement 调用;解构、扩展、可选链,编译成一堆底层的判断和赋值——语法糖,让你"写得爽",但它"实际是什么、怎么运行的",藏在编译产物里。而不懂编译产物的代价是:行为意外(像本文遍历出双倍)、性能意外(某些语法糖编译后开销很大)、调试困难(报错指向你看不懂的编译后代码)。由此,我立下了几条习惯:第一,用一个高层特性(尤其是有"编译魔法"的),要了解它编译成什么;第二,当行为反直觉时,去看它的编译产物(TS Playground 就能看到对应的 JS 输出)——真相,就在那里;第三,优先选择"编译后行为可预测、贴近底层"的写法(就像用 as const 对象,而不是有魔法的 enum)。归根结底:语法糖,是"给人看的便利";而编译产物,才是"机器跑的真相"。理解一个语法糖编译后的样子,你才能预判它的行为、避开它那些"魔法"带来的、像本文这样意料之外的坑。把"只看语法糖"和"看穿编译产物"两种状态对比成一张表:
| 维度 | 只看语法糖(踩坑) | 看穿编译产物(掌握) |
|---|---|---|
| 对 enum | 当纯净的 N 成员枚举 | 知道编译成双向映射对象 |
| 认知依据 | 语法表象 | 编译后的真实代码 |
| 行为反直觉时 | 百思不得其解 | 去看编译产物找真相 |
| 选型 | 凭语法直观 | 选编译后可预测的写法 |
| 典型受害 | enum/async/JSX 的魔法 | 提前预判规避 |
一套"表达一组常量该用什么"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"要表达一组常量/选项、该用什么"的决策图,贴在了团队的 TS 规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:要表达一组常量,先问运行时需不需要遍历这些值——不需要、只要类型约束的,用字面量联合类型(最轻量);需要遍历/序列化的,优先用 as const 对象(行为可预测、无魔法);如果非要用 enum,优先字符串枚举(无反向映射、遍历干净),慎用数字枚举(有双倍坑、遍历要过滤、存 DB 会错位,仅在确实要双向查时才用)。这条"按是否遍历、再按可预测性选型"的决策链,现在是我们团队定义每一组常量时的准则。
我立下的几条 TypeScript 枚举与常量规矩
这次"枚举遍历出双倍"的踩坑,让我把 TS 枚举和常量表达的注意事项,认真地立成了几条规矩:
- 记牢数字枚举有反向映射。编译成有 2N 个键的双向映射对象,直接遍历会拿到数字+名字的双倍条目。
- 表达常量优先 as const 对象 / 字面量联合。行为可预测、坑最少,而非默认用 enum。
- 要用 enum 就用字符串枚举。无反向映射、遍历干净、值可读,别用数字枚举。
- 遍历数字枚举要过滤。过滤掉
isNaN(Number(k))的数字键,只留名字。 - 存 DB/序列化别用数字枚举的隐式数字。顺序一变含义就错位;用字符串值,自解释。
- 慎用 const enum。运行时不存在、不能遍历,有隔离编译的坑。
- 看穿语法糖编译后的样子。enum/async/JSX 都有编译魔法;行为反直觉时去看编译产物。
写在最后
这次"我遍历数字枚举、拿到双倍条目"的经历,是我在 TypeScript 路上,一次很经典、也很受用的成长。它教给我的,远不止"数字枚举有反向映射"这一条具体的技术经验,更是一个关于使用高层语言特性的根本认知——语法糖,是"给人看的便利";而它编译后的产物,才是"机器跑的真相"。我那个双倍的下拉框,根源就在于,我只看到了 enum 这个语法糖"纯净的 N 个成员"的表象,却没看到它编译成 JavaScript 后,那个会"正反各存一份"、有 2N 个键的双向映射对象的真实样子。
所以,当你使用任何一个高层的语法特性、尤其是那些带"编译魔法"的(enum、async/await、JSX、装饰器……)时,请别只满足于"会写这个语法、知道它大概的作用",而要带着好奇,去看一眼它编译/降级之后,到底变成了什么。就像 TS 的数字枚举,你只要在 Playground 里看一眼它编译出的那个双向映射对象,就再也不会被那个"双倍条目"绕晕,反而会觉得"原来如此,理所当然"。透过语法糖的便利、看穿它编译后的真实样子,优先选择那些"行为可预测"的写法,是从一个"会用语法"的开发,走向一个"懂底层、能预判行为"的工程师,必经的修炼。愿你写的每一组常量,都行为如你所料、遍历清清爽爽;也愿你我,在享受每一份语法糖的便利时,都拎得清它背后,机器真正跑着的那份真相。共勉。
—— 别看了 · 2026