【TS】day05-enums-and-literal-types

作者:mario 发布时间: 2026-08-28 阅读量:5 评论数:0

TypeScript 枚举与字面量类型 — 定海神针,一次讲透

程序里到处都是"固定的一组值":设备状态(运行/待机/故障)、告警级别(提示/警告/严重)、协议类型(MQTT/Modbus/OPC-UA)。用裸字符串管理它们,拼错一个字母就是运行时炸弹。枚举与字面量类型就是治理这种混乱的定海神针——今天我们把 enum 的四种形态、字面量联合、as const 三剑客彻底讲透,并给出工业项目的最终选型结论。


目录


一、为什么需要"固定的一组值"?

1.1 裸字符串的灾难现场

先看不用枚举的世界有多危险:

// 某人写的状态渲染
function renderStatus(status: string) {
  if (status === "running") return "🟢 运行中";
  if (status === "standby") return "🟡 待机";
  if (status === "fault")   return "🔴 故障";
  return "❔ 未知";
}

// 三个月后,另一个人写的上报代码
function reportStatus(device) {
  // ⚠ 拼错了!runing
  sendToServer({ status: "runing" });
}

编译期零报错——因为 status: string 接受一切字符串。运行时 renderStatus("runing") 落到兜底分支,页面显示"❔ 未知",你可能三周后才发现产线数据一直在丢失。

1.2 魔法数字的灾难现场

// 这两个 2 和 3 是什么意思?
if (device.status === 2) {
  // ...
}
setTimeout(refresh, 3 * 1000);

数字没有任何自解释性,靠口口相传记忆——第 2 个人接手项目时只能考古。

1.3 核心需求

我们需要一种手段满足三点:

  1. 限定取值范围:status 只能是三种之一,传别的编译期就报错

  2. 自解释:代码里写的是名字,不是魔法数字/拼错的字符串

  3. 双向可查:能从名字查值,也能从值反查名字(日志/调试用)

TypeScript 提供两大家族解决:enum(枚举)字面量类型(literal types)


二、字符串字面量联合:最轻量的方案

2.1 基本语法

第 1 天我们见过字面量类型的雏形。今天正式全面认识它:

// status 只能是这三个字符串之一
type DeviceStatus = "running" | "standby" | "fault";

let s: DeviceStatus;
s = "running";   // ✅
s = "standby";   // ✅
s = "runing";    // ❌ 编译错误:'"runing"' 不在 DeviceStatus 中
s = "RUNNING";   // ❌ 大小写敏感

拼错即刻爆红——1.1 的灾难在编译期就被拦截。

2.2 为什么叫"字面量类型"?

// 普通类型:描述"一类"值
let a: string;   // 任意字符串

// 字面量类型:只描述"一个"值
let b: "running";  // 只能是字符串 "running"

// 字面量联合:若干个"精确值"的或
type DeviceStatus = "running" | "standby" | "fault";

"running" 本身就是一个类型,它的实例只有字符串 "running" 本身。这在 JS 层面不可想象,但 TS 的类型系统允许"值即类型"。

2.3 与 interface/type 组合

type DeviceStatus = "running" | "standby" | "fault";
type AlertLevel = "info" | "warn" | "error";

interface DeviceData {
  id: string;
  status: DeviceStatus;   // 字面量联合做字段类型
  alertLevel: AlertLevel;
}

const d: DeviceData = {
  id: "CNC-001",
  status: "running",
  alertLevel: "warn"
};

// 自动补全体验:输入 d.status = " 时
// IDE 弹出 "running" | "standby" | "fault" 三个选项

2.4 字面量联合的短板

// 短板 1:值与类型耦合 —— 想拿到所有状态列表做下拉框?没法遍历类型
type DeviceStatus = "running" | "standby" | "fault";
// DeviceStatus 是编译期的东西,运行时不存在
// 想渲染 <select><option>running</option>... 必须手写一份运行时数组 → 两处维护

// 短板 2:从值反查"人类可读名"需要自己建映射
const STATUS_TEXT: Record<DeviceStatus, string> = {
  running: "运行中",
  standby: "待机",
  fault: "故障"
};

字面量联合是纯类型层方案,运行时是隐形的——这既是优点(零产物、零开销)也是短板(运行时要用就得配套手写数据)。

2.5 练习

// 练习 1:定义 AlertLevel = "info" | "warn" | "error"
// 写 LEVEL_COLOR: Record<AlertLevel, string> 映射到颜色
// 提示/warn 黄/error 红

// 练习 2:定义 Protocol = "mqtt" | "modbus" | "opcua" | "ethernet"
// 写函数 getProtocolPort(p: Protocol): number(1883/502/4840/502…自查端口,写成常量映射)

三、数字枚举:传统 enum 的第一形态

3.1 基本语法与自动编号

// 数字枚举:不赋值则从 0 开始自动递增
enum DeviceStatus {
  Running,   // 0
  Standby,   // 1
  Fault      // 2
}

let s: DeviceStatus = DeviceStatus.Running;   // 实际值 0

// 也可以自定义起始值
enum Level {
  Info = 1,   // 1
  Warn,       // 2
  Error       // 3
}

3.2 数字枚举的双向映射

enum 编译成真实的运行时对象,数字枚举还带反向查找:

enum DeviceStatus {
  Running,   // 0
  Standby,   // 1
  Fault      // 2
}

// 正向:名字 → 值
console.log(DeviceStatus.Running);            // 0

// ⭐ 反向:值 → 名字(数字枚举特有)
console.log(DeviceStatus[0]);                 // "Running"
console.log(DeviceStatus[1]);                 // "Standby"

// 它的运行时真身(编译产物):
// var DeviceStatus;
// (function (DeviceStatus) {
//   DeviceStatus[DeviceStatus["Running"] = 0] = "Running";
//   DeviceStatus[DeviceStatus["Standby"] = 1] = "Standby";
//   DeviceStatus[DeviceStatus["Fault"] = 2] = "Fault";
// })(DeviceStatus || (DeviceStatus = {}));

这就是 enum 与字面量联合的本质区别:enum 是类型 + 运行时对象的合体;字面量联合是纯类型,编译后消失。

3.3 数字枚举的坑

坑 1:枚举成员的类型就是数字

enum Level { Info = 1, Warn, Error }

let l: Level = Level.Warn;

// ⚠ 数字枚举成员可以直接赋 number!(类型系统开了后门)
let n: number = Level.Warn;    // ✅ 合法:Level 可赋给 number

// 反过来不行
let l2: Level = 2;             // ❌ 报错:number 不能赋给 Level
// ⚠ 但是!直接赋字面量数字却可以:
let l3: Level = 2;             // ✅ 竟然合法!(2 是 Level.Warn 的字面量值)

数字枚举对数字字面量放行——let l3: Level = 2 编译通过。这让"限定取值"的承诺打了折扣。

坑 2:反向映射数组化的类型

// DeviceStatus[0] 的返回类型是 string,不是 DeviceStatus 的成员名联合
const name = DeviceStatus[0];  // string
// 且传越界数字不报错
console.log(DeviceStatus[99]);  // undefined —— 静默失败

坑 3:跨文件/跨版本兼容

// 中间插入一个成员,所有后续数字变化!
enum DeviceStatus {
  Running,    // 0
  Offline,    // 1 ⚠ 新插入
  Standby,    // 2(原来是 1!)
  Fault       // 3(原来是 2!)
}
// 如果旧数据/持久化记录里存了 1(原 Standby),新代码读出来变成 Offline —— 数据错乱

这是数字枚举在工业系统里的高危坑:设备状态存了数据库,枚举中间插值后历史数据全部错位。规范:数字枚举必须显式赋值,永远不依赖自动递增。

3.4 练习

// 练习 1:定义显式赋值的 Protocol enum:Mqtt = 1883, Modbus = 502, OpcUa = 4840
// 写 getProtocolName(p: Protocol): string 用反向映射

// 练习 2:把 3.3 坑 3 的例子跑一遍,验证插入 Offline 后 Standby 的值变化

四、字符串枚举:可读的 enum 形态

4.1 基本语法

enum DeviceStatus {
  Running = "RUNNING",
  Standby = "STANDBY",
  Fault   = "FAULT"
}

let s = DeviceStatus.Running;  // 值是 "RUNNING"(自解释!)

// ⚠ 字符串枚举没有反向映射
console.log(DeviceStatus["RUNNING"]);  // undefined(数字枚举才有反查)

4.2 字符串枚举的编译产物

// 源码
enum DeviceStatus {
  Running = "RUNNING",
  Standby = "STANDBY"
}

// 编译产物:一个普通对象(比数字枚举干净,没有反向查表)
// var DeviceStatus;
// (function (DeviceStatus) {
//   DeviceStatus["Running"] = "RUNNING";
//   DeviceStatus["Standby"] = "STANDBY";
// })(DeviceStatus || (DeviceStatus = {}));

4.3 字符串枚举 vs 字面量联合

// 字符串枚举
enum StatusA {
  Running = "RUNNING",
  Standby = "STANDBY"
}

// 字面量联合
type StatusB = "RUNNING" | "STANDBY";

// ⚠ 关键区别:字符串枚举不兼容字面量!
let a: StatusA = StatusA.Running;

let a2: StatusA = "RUNNING";   // ❌ 报错!枚举要求用枚举成员访问
let b: StatusB = "RUNNING";    // ✅ 直接写字面量就行

// 这带来一个工程痛点:
function isFault(s: StatusA) { return s === StatusA.Fault; }
isFault("FAULT");              // ❌ 不能传裸字符串
isFault(StatusA.Fault);        // ✅ 必须带命名空间

字符串枚举强制走 Enum.Member 命名空间——好处是来源明确、可读性强;代价是与 JSON/接口数据交互时必须转换(后端返回 "FAULT" 字符串,你不能直接当 StatusA 用)。

4.4 字符串枚举的真实价值场景

// 场景:状态值需要与后端/数据库协议严格一致,且要在运行时遍历
enum DeviceStatus {
  Running = "RUNNING",
  Standby = "STANDBY",
  Fault   = "FAULT"
}

// ✅ 运行时可遍历(下拉框、图例)
const statusOptions = Object.values(DeviceStatus);
// ["RUNNING", "STANDBY", "FAULT"]

// ✅ 值本身就是协议内容(日志可读)
console.log(`[监控] CNC-01 状态变更 → ${DeviceStatus.Fault}`);
// [监控] CNC-01 状态变更 → FAULT(而不是 2)

// ✅ 类型安全:写错成员名编译报错
const s = DeviceStatus.Falut;  // ❌ 编译错误:Falut 不存在

4.5 练习

// 练习 1:定义字符串枚举 AlertLevel:Info/Warn/Error = "INFO"/"WARN"/"ERROR"
// 写 LEVEL_TEXT 映射中文,写 renderLevel(l: AlertLevel) 返回 "提示/警告/严重"

// 练习 2:验证字符串枚举不能反查:
// 枚举 AlertLevel,试 AlertLevel["INFO"],看结果

五、const 枚举与异构枚举:enum 的另类形态

5.1 const enum:编译期内联

const enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT"
}

function move(d: Direction) { /* ... */ }

move(Direction.Up);

编译产物(普通 enum vs const enum):

// 普通 enum:生成运行时对象
var Direction;
(function (Direction) {
  Direction["Up"] = "UP";
  // ...
})(Direction || (Direction = {}));
move(Direction.Up);

// const enum:不生成对象,直接内联字面量
move("UP");  // ⭐ 值被直接替换进使用处

const enum 的特点

特性

普通 enum

const enum

运行时产物

真实对象

无(内联替换)

可遍历(Object.values)

❌(没有对象可遍历)

反向映射(数字)

bundle 体积

有开销

零开销

isolatedModules 兼容性

⚠ 需 TS 5+

重要提醒:在 Vite/esbuild 等基于 isolatedModules 的现代构建工具中,const enum 历史上是不支持的(会直接报错或不内联)。TS 5.0 起 esbuild/流水线已能处理,但跨包使用 const enum 仍是雷区。初学阶段建议:知道它存在,项目里统一不用,需要零开销就用下一节的 as const 方案。

5.2 异构枚举(Heterogeneous Enum):不推荐

// 数字和字符串混着用 —— 官方文档明确不建议
enum Result {
  Ok = 0,
  Message = "OK"
}

异构枚举的成员类型不一致,双向映射混乱,纯属给自己挖坑。见到就重构

5.3 enum 成员的冷知识:计算成员

// 枚举成员可以是计算值
enum Permission {
  None = 0,
  Read = 1 << 0,      // 1(位运算)
  Write = 1 << 1,     // 2
  Execute = 1 << 2,   // 4
  All = Read | Write | Execute  // 7
}

// 位标志枚举:一个值同时表示多个权限(Linux 文件权限的经典思想)
const p = Permission.Read | Permission.Write;  // 3 = 读+写

function canRead(perm: Permission): boolean {
  return (perm & Permission.Read) !== 0;
}

位标志在工业协议(PLC 权限位、CAN 报文标志位)中真实存在,属于读得懂级别即可,日常业务优先级低。

5.4 练习

// 练习:用位标志 Permission 实现 canWrite(perm)、hasAll(perm)
// 验证 (Read | Write) & Write !== 0

六、as const + typeof:字面量的完全体

6.1 问题:字面量联合的两处维护

第二节留的尾巴——字面量联合是纯类型,运行时要配套手写数组:

type DeviceStatus = "running" | "standby" | "fault";

// 运行时需要列表(下拉框/图例),只能手写一份 ⚠
const STATUS_LIST = ["running", "standby", "fault"];
// ⚠ 新增状态时要改两处,漏一处就出 bug

6.2 as const:把数组"冻"成字面量元组

// 第一步:写运行时常量(唯一的真相源)
const STATUS_LIST = ["running", "standby", "fault"] as const;
// STATUS_LIST 的类型:readonly ["running", "standby", "fault"]

// 第二步:typeof + 索引访问,从常量推导类型(明天正式学,今天先看一眼)
type DeviceStatus = (typeof STATUS_LIST)[number];
// DeviceStatus = "running" | "standby" | "fault" ✅ 自动推导!

这 3 行代码就是现代 TS 的标准答案

  • 运行时:STATUS_LIST 是真实数组,能遍历能渲染下拉框

  • 类型层:DeviceStatus 自动跟随 STATUS_LIST 推导

  • 单一真相源:新增状态只改 STATUS_LIST 一处,类型自动更新

6.3 as const 的完整效果

const CONFIG = {
  url: "ws://localhost:8080",
  reconnect: true,
  levels: ["info", "warn", "error"],
  size: [1920, 1080]
} as const;

// CONFIG 的类型(每个属性都变成最深度的只读字面量):
// {
//   readonly url: "ws://localhost:8080";
//   readonly reconnect: true;
//   readonly levels: readonly ["info", "warn", "error"];
//   readonly size: readonly [1920, 1080];
// }

CONFIG.url = "x";     // ❌ 只读,不能改
CONFIG.levels.push("fatal");  // ❌ 只读数组没有 push

as const 做了三件事

  1. 所有属性变 readonly

  2. 所有值收窄为字面量类型true 而不是 boolean"ws://..." 而不是 string

  3. 数组变 readonly 元组(长度和每个位置的类型都锁定)

6.4 完整对比:同一需求的三种写法

// ===== 需求:告警级别,运行时要中文映射 + 列表,类型层要安全 =====

// ❌ 方案 A:纯字面量联合 + 手写数据(两处维护)
type Level1 = "info" | "warn" | "error";
const TEXT1 = { info: "提示", warn: "警告", error: "严重" } as const;

// ⚠ 方案 B:字符串枚举(运行时对象 + 需要映射表,与裸字符串不互通)
enum Level2 { Info = "info", Warn = "warn", Error = "error" }

// ✅ 方案 C:as const 对象(一个对象,类型和值全齐)
const LEVELS = {
  info:  { text: "提示", color: "#2196f3" },
  warn:  { text: "警告", color: "#ff9800" },
  error: { text: "严重", color: "#f44336" }
} as const;

type Level = keyof typeof LEVELS;       // "info" | "warn" | "error"(明天细讲 keyof)
type LevelInfo = (typeof LEVELS)[Level]; // { text: "提示"; color: "#2196f3" } | ...

// 使用:值与类型双全
function renderLevel(l: Level): string {
  return LEVELS[l].text;   // ✅ 映射和类型同源,永不脱节
}
renderLevel("warn");       // ✅
renderLevel("fatal");      // ❌ 编译错误

6.5 练习

// 练习 1:定义 PROTOCOLS = { mqtt: 1883, modbus: 502, opcua: 4840 } as const
// 推导 Protocol 类型;写 getPort(p: Protocol): number

// 练习 2:定义 STATUS = { running: {...}, standby: {...}, fault: {...} } as const
// 含 text/color/svg 三字段,写 renderStatusBadge(s: keyof typeof STATUS)

七、四大方案终极对比与选型

7.1 全维度对比表

维度

字面量联合

数字枚举

字符串枚举

as const 对象

运行时产物

对象+反查表

对象

对象(你手写的)

拼错保护

裸值直传("running"

⚠ 数字字面量可

运行时可遍历

值可读(日志友好)

❌(是数字)

单一真相源

❌ 类型/数据两处

bundle 开销

你写的多大就多大

JSON/后端交互

✅ 直接

⚠ 要转

⚠ 要转

✅ 直接

重构改名

IDE 全局改类型

IDE 改枚举

IDE 改枚举

IDE 全局改

与映射表配合

需另建

Record<Enum, T>

Record<Enum, T>

自带(最顺)

数字语义(位标志)

7.2 选型决策树

需要"固定的一组值"?
│
├── 值是数字且有位运算需求(协议标志位/权限位)
│   └── ✅ 数字枚举(显式赋值!)或直接用常量位运算
│
├── 纯类型层使用(函数参数/返回值/字段标注),运行时不需要列表
│   └── ✅ 字面量联合(最轻,零开销)
│
├── 运行时需要列表/映射(下拉框、图例、颜色、文案),值要与后端协议一致
│   └── ✅ as const 对象 + keyof typeof(单一真相源,现代首选)
│
└── 团队规范已统一用 enum(存量代码一致性)
    └── ✅ 字符串枚举(可读 + 可遍历),显式赋值

7.3 一句话结论

新项目默认 as const 对象(含映射信息时)或字面量联合(纯类型时);enum 只在需要遍历 + 团队规范统一时用字符串枚举、位运算场景用数字枚举。

这也是 Vue3/Pinia 等现代流行库源码的实际选择——它们的类型定义几乎全是字面量联合 + as const。


八、映射的钥匙:keyof 与枚举对象

8.1 Record + 联合 = 完整映射表

type DeviceStatus = "running" | "standby" | "fault";

// Record<K, V>:K 的每个成员都必须有对应的 V(明天细讲,今天记住形态)
const STATUS_CONFIG: Record<DeviceStatus, { text: string; color: string }> = {
  running: { text: "运行中", color: "#00ff88" },
  standby: { text: "待机", color: "#ffaa00" },
  fault:   { text: "故障", color: "#ff4444" }
};

// ✅ Record 的强制完整性:漏写一个成员编译报错
const BAD: Record<DeviceStatus, string> = {
  running: "运行中",
  standby: "待机"
  // ❌ 缺 fault —— 编译错误:Property 'fault' is missing
}

8.2 enum 的 Record 映射

enum DeviceStatus {
  Running = "RUNNING",
  Standby = "STANDBY",
  Fault   = "FAULT"
}

const STATUS_CONFIG: Record<DeviceStatus, string> = {
  [DeviceStatus.Running]: "运行中",
  [DeviceStatus.Standby]: "待机",
  [DeviceStatus.Fault]:   "故障"
};

function renderStatus(s: DeviceStatus): string {
  return STATUS_CONFIG[s];  // ✅ 类型保证映射完整
}

8.3 as const 对象的映射是"自带的"

// 第六节方案 C 的最大优势:映射即真相源,不存在"另建映射表"这步
const LEVELS = {
  info:  { text: "提示",  color: "#2196f3" },
  warn:  { text: "警告",  color: "#ff9800" },
  error: { text: "严重",  color: "#f44336" }
} as const;

// 遍历(下拉框):Object.entries 保留键值对
Object.entries(LEVELS).forEach(([key, cfg]) => {
  console.log(key, cfg.text, cfg.color);
});

8.4 练习

// 练习:用 Record 给三种协议建 "默认端口 + 是否需要心跳" 的配置表
// mqtt: 1883/true, modbus: 502/false, opcua: 4840/true
// 故意漏写一个,看编译器报错信息

第九节占位说明

(本节并入第九节"穷尽检查"与第十节"实战" —— 保持目录连续编号。)


九、穷尽检查:字面量类型的守护神

9.1 复习 + 字面量版穷尽检查

第 4 天学过判别联合 + never 穷尽检查。字面量联合同样享受这一保护:

type DeviceStatus = "running" | "standby" | "fault";

function getIcon(status: DeviceStatus): string {
  switch (status) {
    case "running": return "🟢";
    case "standby": return "🟡";
    case "fault":   return "🔴";
    default: {
      const _exhaustive: never = status;  // ⭐ 守护
      return _exhaustive;
    }
  }
}

9.2 新增状态的连锁保护

// 新增 "offline"(离线)状态
type DeviceStatus = "running" | "standby" | "fault" | "offline";

// getIcon 未更新 → default 里 status 是 "offline" ≠ never → 编译报错!
// 同样,STATUS_CONFIG: Record<DeviceStatus, ...> 也会立刻报缺成员
// ⭐ 这就是"单一真相源"的复利:类型一处改,所有遗漏处集体爆红

9.3 对象映射的穷尽(无 default 需求)

// Record 本身就是穷尽检查 —— 表里没写全就编译失败
// 比手写 switch 更防漏:switch 可能忘写 default + never,Record 忘写直接红
const STATUS_CONFIG: Record<DeviceStatus, string> = {
  running: "运行中",
  standby: "待机",
  fault: "故障",
  offline: "离线"
};

工程心法能用 Record 映射就别写 switch——映射表是数据(可遍历、可扩展),switch 是逻辑(不可遍历、容易漏分支)。


十、实战场景全覆盖

10.1 场景一:设备状态体系(as const 完全体)

// ===== 状态体系:单一真相源 =====
export const DEVICE_STATUS = {
  running: { text: "运行中", color: "#00ff88", icon: "🟢", level: 0 },
  standby: { text: "待机",   color: "#ffaa00", icon: "🟡", level: 1 },
  fault:   { text: "故障",   color: "#ff4444", icon: "🔴", level: 2 },
  offline: { text: "离线",   color: "#7a8ba0", icon: "⚪", level: 3 }
} as const;

export type DeviceStatus = keyof typeof DEVICE_STATUS;
export type StatusConfig = (typeof DEVICE_STATUS)[DeviceStatus];

// ===== 使用 =====
function renderBadge(s: DeviceStatus): string {
  const cfg = DEVICE_STATUS[s];
  return `<span style="color:${cfg.color}">${cfg.icon} ${cfg.text}</span>`;
}

// 下拉框选项(运行时遍历)
const options = Object.entries(DEVICE_STATUS).map(([value, cfg]) => ({
  value,
  label: cfg.text
}));

// 判断逻辑(类型收窄自动跟随)
function isCritical(s: DeviceStatus): boolean {
  return DEVICE_STATUS[s].level >= 2;
}

10.2 场景二:协议端口表(字面量联合 + Record)

type Protocol = "mqtt" | "modbus" | "opcua" | "ethernet";

interface ProtocolConfig {
  port: number;
  heartbeat: boolean;
  desc: string;
}

export const PROTOCOLS: Record<Protocol, ProtocolConfig> = {
  mqtt:     { port: 1883, heartbeat: true,  desc: "消息队列遥测传输" },
  modbus:   { port: 502,  heartbeat: false, desc: "工控总线协议" },
  opcua:    { port: 4840, heartbeat: true,  desc: "OPC 统一架构" },
  ethernet: { port: 502,  heartbeat: false, desc: "工业以太网" }
};

function connect(p: Protocol): string {
  const cfg = PROTOCOLS[p];
  return `连接 ${cfg.desc} @ ${cfg.port}${cfg.heartbeat ? "(心跳开启)" : ""}`;
}

10.3 场景三:WS 消息类型(字符串枚举版)

// 团队规范用 enum 的写法(对照学习)
export enum WsEvent {
  Connect = "connect",
  Data    = "data",
  Error   = "error",
  Close   = "close"
}

// 消息类型定义(判别字段用枚举成员)
type WsMessage =
  | { event: WsEvent.Connect; sessionId: string }
  | { event: WsEvent.Data; payload: unknown }
  | { event: WsEvent.Error; code: number; message: string }
  | { event: WsEvent.Close; reason: string };

// 分发(第 4 天穷尽检查照样生效)
function handle(msg: WsMessage) {
  switch (msg.event) {
    case WsEvent.Connect: return `会话 ${msg.sessionId}`;
    case WsEvent.Data:    return `数据 ${msg.payload}`;
    case WsEvent.Error:   return `错误 ${msg.code}: ${msg.message}`;
    case WsEvent.Close:   return `关闭:${msg.reason}`;
    default: {
      const _exhaustive: never = msg;
      return _exhaustive;
    }
  }
}

10.4 场景四:主题色板(as const 嵌套)

// 大屏主题:深色工业风色板(as const 嵌套对象)
export const THEME = {
  dark: {
    bg:     "#0a1628",
    panel:  "#0f2035",
    border: "#1a3a5c",
    text:   "#e0e8f0",
    accent: "#00ff88",
    series: ["#00ff88", "#ffaa00", "#3897f0", "#ff4444"]
  },
  light: {
    bg:     "#f5f7fa",
    panel:  "#ffffff",
    border: "#dce4ec",
    text:   "#1a2a3a",
    accent: "#00b368",
    series: ["#00b368", "#e68a00", "#2f7fd6", "#d64545"]
  }
} as const;

export type ThemeName = keyof typeof THEME;              // "dark" | "light"
export type Theme = (typeof THEME)[ThemeName];

// 主题切换(类型跟随)
let current: ThemeName = "dark";
function getTheme(): Theme {
  return THEME[current];
}

// 系列色循环取色
function pickColor(i: number): string {
  const series = getTheme().series;
  return series[i % series.length];
}

10.5 场景五:权限位标志(数字枚举的正当场景)

// 工业系统权限:读/写/配置/管理,用位表示可组合
export enum Permission {
  None     = 0,
  Read     = 1 << 0,  // 0001
  Write    = 1 << 1,  // 0010
  Config   = 1 << 2,  // 0100
  Admin    = 1 << 3   // 1000
}

// 组合权限
const operator = Permission.Read | Permission.Write;      // 0011
const engineer = Permission.Read | Permission.Write | Permission.Config;

// 检查权限
function has(perm: Permission, need: Permission): boolean {
  return (perm & need) === need;
}

has(operator, Permission.Write);   // true
has(operator, Permission.Config);  // false

// 越权操作拦截
function tryConfig(perm: Permission): string {
  if (!has(perm, Permission.Config)) {
    return "❌ 权限不足:需要 Config 权限";
  }
  return "✅ 允许配置";
}

十一、类比记忆:路牌 vs 编号牌

把"固定的一组值"想成城市的管理方案:

方案

类比

特点

裸字符串

路人随口指路(“往前走到那个啥再右转”)

拼错没人管,运行时炸

字面量联合

官方路牌(限定的路名集合)

零成本,但只在地图上(类型层)

数字枚举

门牌编号(0 号、1 号、2 号)

能反查(编号 → 门名),但编号本身没含义,插新房号会乱

字符串枚举

带名字的门牌(“RUNNING 路 1 号”)

可读可遍历,但访客必须报全称

as const 对象

市政厅的花名册(名字+地址+电话一应俱全)

一个本子全搞定,类型层自动同步

const enum

只在规划图上的路牌(实际不立牌,导航直接念路名)

零成本但跨区(跨包)施工会出事故


十二、常见坑点与最佳实践

坑点 1:数字枚举自动递增 + 中途插值

// ❌ 依赖自动递增
enum Status { Running, Standby, Fault }
// 三个月后插入 Offline → 数据库历史数据错位

// ✅ 显式赋值
enum Status { Running = 0, Offline = 10, Standby = 20, Fault = 99 }
// 留出编号空间,插值不挪旧值

坑点 2:字符串枚举与裸字符串不互通

enum Level { Info = "INFO" }

// ❌ 后端返回的字符串不能直接用
const fromApi: string = "INFO";
const l: Level = fromApi;   // 编译错误

// ✅ 需要守卫转换(又是第 4 天的知识!)
function isLevel(v: unknown): v is Level {
  return Object.values(Level).includes(v as Level);
}

坑点 3:忘记 as const,联合退化

// ❌ 没有 as const:类型是 string[],推导不出字面量
const LEVELS = ["info", "warn", "error"];
type Level = (typeof LEVELS)[number];  // string(不是联合!)

// ✅ as const 后才是字面量元组
const LEVELS2 = ["info", "warn", "error"] as const;
type Level2 = (typeof LEVELS2)[number];  // "info" | "warn" | "error"

坑点 4:遍历数字枚举会把反向映射也遍历出来

enum Status { Running = 0, Standby = 1 }

Object.keys(Status);    // ["0", "1", "Running", "Standby"] ⚠️
Object.values(Status);  // ["Running", "Standby", 0, 1]    ⚠️

// ✅ 数字枚举别直接遍历;要遍历用字符串枚举或 as const 对象

坑点 5:Record 映射与枚举键的写法

// ✅ 枚举做键用计算属性名
const M: Record<Status, string> = {
  [Status.Running]: "运行中",
  [Status.Standby]: "待机"
};

// ❌ 直接写字符串键(即使值相等)不匹配类型
const M2: Record<Status, string> = {
  RUNNING: "运行中"   // 编译错误(键类型不对)
};

坑点 6:const enum 在 Vite 中的兼容性

// ⚠ Vite(isolatedModules)环境下 const enum 可能报错或不内联
// 规则:同文件内用没问题;跨文件/跨包导出 const enum 是雷区
// 初学建议:不用 const enum,要零开销就 as const

坑点 7:字面量联合被宽类型吞噬

type Level = "info" | "warn";

// ❌ 接口字段写成 string,联合白定义
interface Bad { level: string }

// ✅ 字段类型精确到联合
interface Good { level: Level }

// ⚠ 注意数组推断
const arr = ["info", "warn"];        // string[]
const arr2 = ["info", "warn"] as const;  // readonly ["info", "warn"]

最佳实践清单

  1. 新代码首选 as const 对象(带映射信息)或字面量联合(纯类型层)

  2. enum 必须显式赋值(数字枚举防插值错位;字符串枚举保协议一致)

  3. 映射表用 Record<联合, 配置>,让编译器强制你写全

  4. 下拉框/图例数据从 as const 对象遍历生成,单一真相源

  5. switch 分发必带穷尽检查;但优先考虑 Record 映射替代 switch

  6. 不使用异构枚举不跨包导出 const enum

  7. 外部字符串转枚举必须走守卫(第 4 天技能复用)

  8. 位标志场景(权限/协议位)用数字枚举 + 位运算


十三、自测挑战

Q1type S = "a" | "b"enum S { A = "a", B = "b" },运行时产物有什么区别?

Q2:数字枚举的反向映射是怎么实现的(编译产物角度)?

Q3:为什么数字枚举中间插入成员会导致历史数据错位?如何预防?

Q4:字符串枚举的值 "FAULT" 能直接赋给 Level 类型的变量吗?为什么?

Q5const arr = ["a", "b"]const arr = ["a", "b"] as const 的类型有什么区别?

Q6:写三行代码实现"从 ["info", "warn", "error"] 推导出联合类型"。

Q7Record<DeviceStatus, string> 映射表漏写一个成员会发生什么?这为什么是好事?

Q8:遍历数字枚举的 Object.values() 会得到什么意外结果?

Q9:const enum 和普通 enum 的编译产物差异?为什么现代构建工具对它有兼容性顾虑?

Q10:位标志权限 Permission.Read | Permission.Write 的值是多少?has(perm, need) 怎么实现?

Q11:后端返回字符串 "running",如何安全转成 DeviceStatus 枚举/联合?(提示:守卫)

Q12:什么场景下你会放弃 as const 对象、选择数字枚举?


十四、总结与知识图谱

枚举与字面量类型(固定值的治理术)
│
├── 需求起源
│   ├── 裸字符串:拼错编译期无感知 → 运行时静默失败
│   └── 魔法数字:无自解释性 → 靠考古维护
│
├── 字面量联合(type A = "x" | "y")
│   ├── 值即类型,编译后消失(零开销)
│   ├── 拼错/大小写错误 → 编译报错
│   └── 短板:运行时不可遍历(需配套数据 → 两处维护)
│
├── enum 家族(类型 + 运行时对象合体)
│   ├── 数字枚举
│   │   ├── 自动递增(⚠ 插值错位,必须显式赋值)
│   │   ├── 反向映射(值 → 名,编译产物双写)
│   │   ├── ⚠ 数字字面量可直传(承诺打折)
│   │   └── 位标志场景(Permission:读/写/配置/管理)
│   ├── 字符串枚举
│   │   ├── 值自解释(日志友好)、可遍历
│   │   └── ⚠ 与裸字符串不互通(需守卫转换)
│   ├── const enum(编译期内联,跨包雷区,初学不用)
│   └── 异构枚举(❌ 见到就重构)
│
├── as const 三剑客(现代首选)
│   ├── as const:全只读 + 字面量化 + 元组化
│   ├── (typeof X)[number]:数组 → 联合
│   └── keyof typeof X:对象键 → 联合(明天正式学 keyof)
│   └── 价值:单一真相源(类型与数据永不脱节)
│
├── 配套设施
│   ├── Record<联合, 配置>:强制完整的映射表
│   └── 穷尽检查(never):新增成员 → 所有漏改处爆红
│
└── 选型决策树
    ├── 位运算/协议位 → 数字枚举(显式赋值)
    ├── 纯类型层 → 字面量联合
    ├── 需列表/映射/协议值 → as const 对象
    └── 团队规范统一 → 字符串枚举

一句话总结:字面量联合是"零成本的类型路牌",enum 是"可遍历的运行时门牌",as const 对象是"类型与数据合一的市政花名册"——新项目优先花名册,纯类型用路牌,enum 留给位运算和团队规范。


延伸阅读

资源

说明

TypeScript Handbook - Enums

官方枚举文档

TypeScript Handbook - Literal Types

官方字面量类型文档

TypeScript Handbook - Narrowing

穷尽检查的原理

TS 5.0 const enum 与 isolatedModules

官方对 const enum 的建议

TypeScript Playground

在线验证所有编译产物


下一步

本文是 TypeScript 深入 系列的第 5 天。接下来的学习路线:

  • 第 6 天:keyof / typeof / 索引访问 — 类型钥匙(typeof 已在今天预告,keyof 明天全面展开)

  • 第 7 天:第一关 BOSS 战 — 综合实战(本周全部知识融成一个工业数据处理模块)


学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。

每天花 2 小时,28 天通关 TypeScript 深入。加油!


评论