06-枚举与字面量类型
枚举与字面量类型
概述
Go 使用 iota 实现枚举常量,类型安全但功能有限。TypeScript 提供了两种枚举机制:传统的 enum(数字/字符串枚举)和更推荐的字面量联合类型 + as const。对于 Go 开发者来说,理解为何 TS 社区逐渐倾向于用字面量类型替代 enum,是掌握 TS 类型风格的重要一步。
Go 开发者已知
// Go 枚举 —— iota
type Status int
const (
StatusActive Status = iota // 0
StatusInactive // 1
StatusPending // 2
StatusDeleted // 3
)
// 自定义起始值
const (
StatusA Status = iota + 10 // 10
StatusB // 11
StatusC // 12
)
// 跳过某些值
const (
StatusX Status = iota // 0
_ // 1(跳过)
StatusY // 2
)
// 字符串枚举
type Color string
const (
ColorRed Color = "red"
ColorGreen Color = "green"
ColorBlue Color = "blue"
)Go 枚举特点:
- 基于
iota的整数自增 - 类型安全(不同枚举类型不能混用)
- 表达能力有限,无法包含方法
- 没有反射级的枚举遍历能力
TypeScript 怎么做
数字枚举
enum Status {
Active, // 0
Inactive, // 1
Pending, // 2
Deleted, // 3
}
// 自定义初始值
enum StatusExplicit {
Active = 1,
Inactive = 2,
Pending = 4,
Deleted = 8, // 位标志
}
// 使用
const s: Status = Status.Active
console.log(s) // 0
console.log(Status[0]) // "Active"(反向映射)字符串枚举
enum Color {
Red = 'red',
Green = 'green',
Blue = 'blue',
}
// 字符串枚举没有反向映射
console.log(Color.Red) // "red"
// console.log(Color['red']) // Error
// 混合枚举(不推荐)
enum Mixed {
Yes = 1,
No = 'no',
}const enum(常量枚举)
const enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
// const enum 在编译时被内联,没有运行时产物
const dir = Direction.Up // 编译后: const dir = 'UP'const enum 的限制
const enum 不能在使用 isolatedModules: true 的构建工具(如 Vite、Babel)中使用。如需跨文件常量枚举,使用普通 enum 或 as const 替代。
字面量联合类型(推荐)
// 用字面量联合类型替代 enum
type Status = 'active' | 'inactive' | 'pending'
function handleStatus(s: Status): void {
switch (s) {
case 'active':
break
case 'inactive':
break
case 'pending':
break
default:
const _exhaustive: never = s
}
}| 操作符的学名:联合类型符号
type Status = 'active' | 'inactive' | 'pending' 中的 |,在类型上下文(type position)中的正式名称是 联合类型符号(Union Type Symbol),它不是位或运算符(Bitwise OR)。
| 上下文 | | 的含义 | 学名 | 出现位置 |
|--------|-----------|------|---------|
| 类型位置 | 类型联合 | 联合类型符号 | type X = A \| B、let x: string \| number |
| 值位置 | 按位或 | 位或运算符 | const flags = Read \| Write、a \| b |
| 表达式位置 | 逻辑或(备选) | 逻辑或 | const x = a \| b(实际是位或,对布尔是逻辑或) |
TS 编译器根据 | 出现的位置自动区分:
// —— 类型位置:联合类型符号 ——
type Status = 'active' | 'inactive' // A 或 B 之一
let id: string | number // string 或 number 之一
// —— 值位置:位或运算符 ——
const mask = FileAccess.Read | FileAccess.Write // 运行时位运算,结果是数字
// 注意:值位置永远不可能出现"类型联合"关键区别:type X = A | B 中的 | 声明的是**"A 或 B 之一"的类型约束,它在编译期完全被擦除;而 Read | Write 中的 | 是运行时执行的位运算指令**,产生一个新的数字值。
Go 中没有对应的概念——Go 的类型系统不支持联合类型,所以同一个 | 符号不会出现歧义,永远只有位或运算一种含义。
as const
// as const 将对象变为深度只读常量
const Colors = {
Red: '#FF0000',
Green: '#00FF00',
Blue: '#0000FF',
} as const
// Colors.Red 的类型是 '#FF0000'(字面量类型),而非 string
// 获取值的联合类型
type ColorValue = (typeof Colors)[keyof typeof Colors]
// '#FF0000' | '#00FF00' | '#0000FF'
// as const 与数组
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const
type HttpMethod = (typeof HTTP_METHODS)[number]
// 'GET' | 'POST' | 'PUT' | 'DELETE'差异分析
| 维度 | Go | TypeScript |
|---|---|---|
| 枚举机制 | iota 自增 | enum / 字面量联合 / as const |
| 类型安全 | 强类型独立枚举 | 结构兼容,枚举间可赋值 |
| 运行时开销 | 无 | enum 有运行时对象;const enum/字面量无 |
| 反向映射 | 不支持 | 数字 enum 支持 |
| 位标志 | 手动 iota 位移 | 数字枚举支持 |
| 字符串枚举 | const string 模拟 | 原生支持 |
| 推荐实践 | iota | 优先 as const + 联合类型 |
为何 TS 社区倾向用字面量类型替代 enum?
- Tree-shaking 友好:
enum编译为 IIFE,无法被树摇 - 类型更精确:字面量联合类型在类型检查时更严格
- 与数据交互自然:后端 API 返回的字符串天然是字面量类型
- 与外部接口兼容:JSON 序列化/反序列化不需要转换逻辑
// enum —— 运行时存在,无法 tree-shake
enum Status { Active = 'active' }
// 字面量联合 —— 纯编译时,零运行时开销
type Status = 'active' | 'inactive'Bad Practice
// Bad: 数字枚举的隐式赋值导致安全隐患
enum Permission {
Read, // 0
Write, // 1
Execute, // 2
// 如果以后在 Read 前插入一个新值,所有值都会变!
}
// Bad: 混用不同类型枚举值
enum Mixed {
Yes = 1,
No = 'no', // 混合数字和字符串
}
// Bad: 使用 enum 表达单纯的字符串常量组
enum Colors {
Red = '#FF0000',
Green = '#00FF00',
}
// 直接用 as const + 联合类型更好
// Bad: 依赖数字枚举的隐式值
enum Fruit {
Apple, // 假设是 0
Banana, // 1
Orange, // 2
}
// 如果有人添加了条目,所有索引都会受影响Best Practice
// Good: 优先 as const + 联合类型
const Colors = {
Red: '#FF0000',
Green: '#00FF00',
Blue: '#0000FF',
} as const
type Colors = keyof typeof Colors // 'Red' | 'Green' | 'Blue'
type ColorValues = (typeof Colors)[keyof typeof Colors]
// '#FF0000' | '#00FF00' | '#0000FF'
// Good: 当需要运行时枚举行为时使用普通 enum
enum HttpStatus {
OK = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
InternalServerError = 500,
}
// 利用数字枚举的反向映射调试
console.log(HttpStatus[200]) // "OK"
// Good: 字面量联合类型 + 穷举检查
type Direction = 'north' | 'south' | 'east' | 'west'
function move(direction: Direction): void {
switch (direction) {
case 'north': break
case 'south': break
case 'east': break
case 'west': break
default: {
const _exhaustive: never = direction
}
}
}
// Good: 位标志使用数字枚举
enum FileAccess {
None = 0,
Read = 1 << 0,
Write = 1 << 1,
Execute = 1 << 2,
ReadWrite = Read | Write,
All = Read | Write | Execute,
}
#### 枚举包含关系分析
用 `|` 拼接的数值型枚举变量,其值是**运行时位运算的结果**。变量本身只存储**一个数字**,这个数字的二进制位中哪些位为 1,就代表包含哪些枚举值。
```ts
enum Permission {
Read = 1 << 0, // 二进制 001,值 = 1
Write = 1 << 1, // 二进制 010,值 = 2
Execute = 1 << 2, // 二进制 100,值 = 4
}
// 拼接
const rw = Permission.Read | Permission.Write // 值 = 3(二进制 011)
const all = Permission.Read | Permission.Write | Permission.Execute // 值 = 7(二进制 111)分析包含关系(检查某个权限是否存在):
// 使用 & 操作符做"位与"判断
function hasPermission(mask: number, perm: Permission): boolean {
return (mask & perm) === perm
}
const mask = Permission.Read | Permission.Write // 值 3
hasPermission(mask, Permission.Read) // true,因为 3 & 1 = 1 === 1
hasPermission(mask, Permission.Write) // true,因为 3 & 2 = 2 === 2
hasPermission(mask, Permission.Execute) // false,因为 3 & 4 = 0 !== 4位运算原理:
Permission.Read = 001₂ = 1
Permission.Write = 010₂ = 2
─────────────────
Read | Write = 011₂ = 3 ← 每一位代表"是否包含该标志"
检查 Read: 011 & 001 = 001 → 非零 → 包含
检查 Write: 011 & 010 = 010 → 非零 → 包含
检查 Execute: 011 & 100 = 000 → 零 → 不包含关键要点:
Read | Write的类型是Permission(数值枚举类型),但变量的值是3(一个数字)- 使用
&操作符做位掩码检查来判定包含关系 - 每个枚举值必须设计为2 的幂(
1 << n),才能通过位运算无损组合和分解 - 枚举值本身不存储"包含关系"信息——包含关系是通过外部位运算分析得到的
// 完整示例:权限系统
enum Permission {
None = 0,
Read = 1 << 0, // 1
Write = 1 << 1, // 2
Execute = 1 << 2, // 4
All = Read | Write | Execute, // 7
}
class File {
constructor(public permissions: number = Permission.Read) {}
can(perm: Permission): boolean {
return (this.permissions & perm) === perm
}
grant(perm: Permission): void {
this.permissions |= perm
}
revoke(perm: Permission): void {
this.permissions &= ~perm
}
list(): string[] {
const result: string[] = []
if (this.can(Permission.Read)) result.push('Read')
if (this.can(Permission.Write)) result.push('Write')
if (this.can(Permission.Execute)) result.push('Execute')
return result
}
}
const file = new File()
file.grant(Permission.Write)
console.log(file.list()) // ['Read', 'Write']
console.log(file.can(Permission.Execute)) // false如何选择枚举方案?
| 场景 | 推荐方案 |
|---|---|
| 简单字符串常量组 | as const + 联合类型 |
| 需要穷举检查的 switch | 字面量联合类型 |
| 需要运行时遍历 | 普通 enum |
| 位标志操作 | 数字 enum |
| 外部 API 数据(JSON) | 字面量联合类型 |
| 需要反向映射(调试) | 数字 enum |
| 构建工具不支持 const enum | 字面量联合类型或普通 enum |
code-tabs 对比
总结
| Go 习惯 | TypeScript 对应 |
|---|---|
iota 数字枚举 | enum 数字枚举 或 as const |
string 常量组 | 字面量联合类型 |
| 位标志 | 数字枚举位运算 |
| 类型安全枚举 | 字面量类型 + 穷举检查 |
| 反射遍历 | Object.values(enum) |
核心建议:在 TypeScript 中,优先使用 as const + 字面量联合类型,仅在需要运行时特性(反向映射、位运算、迭代)时才使用 enum。
下一章将介绍 类型守卫与类型断言。
附录:常见问题(QA)
Q1:为什么字面量可以和类型绑在一起?如何理解字面量类型?
要理解字面量类型(Literal Types),关键在于区分 "值"(value) 与 "值的类型"(type of a value) 这两个通常被混为一谈的概念。
值的两层信息
任何一个值都携带两层信息:
const x = 42
// 值本身:42
// 值的类型:number(范围是所有数字)在普通情况下,类型是范围(set of values),范围越大,约束越弱。字面量类型的革命性在于:允许用一个具体的值本身来充当类型,从而将类型的范围缩小到只有一个元素:
// 普通类型 —— 范围很大
let a: number // 可以赋任何数字
// 字面量类型 —— 范围缩小到"只有一个值"
type FortyTwo = 42
const b: FortyTwo = 42 // OK
// const c: FortyTwo = 43 // Error: Type '43' is not assignable to type '42'直观理解:把字面量看作"单例类型"
可以这样类比:
number是一个集合,包含... -1, 0, 1, 2, ..., 42, ...等无穷多个元素42(作为字面量类型)是一个只包含42这一个元素的单例集合
所以"字面量和类型绑在一起"的本质是:类型系统允许将"值"提升到"类型层"使用,使类型描述能力从"是什么种类的值"扩展为"具体是哪个值"。
换句话说
// 普通类型:描述"值的种类"
type T1 = string // 任何字符串都可以
// 字面量类型:描述"值的身份"
type T2 = 'hello' // 只有 'hello' 这个字符串可以
type T3 = 42 // 只有 42 这个数字可以
type T4 = true // 只有 true 这个布尔值可以类比 Go 帮助理解
Go 没有字面量类型的概念,但有一个接近的机制:const 的值可以在编译期参与类型检查:
// Go 中,const 的值是编译期常量
const MaxSize = 100
// 但以下不成立:
// var x 100 // Error:100 不能作为类型
// 在 TypeScript 中,100 本身就是一种类型
// type Exactly100 = 100
// let x: Exactly100 = 100 // OKGo 的 const 只在值层有限使用;TypeScript 的字面量类型将值提升到了类型层,让编译器能够区分"42"和"43"这样的具体值,而不是笼统地认为它们都是 number。
字面量类型在联合类型中的威力
当多个字面量类型组合成联合类型时,其真正威力就显现出来了——你得到的是一个有限的、精确的值集合:
// 字面量联合类型 = 精确枚举所有可能的值
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
// 等价于:HttpMethod 的值只能是这四个字符串之一
// 不接收任意 string,也不接收 'PATCH'这正是 Go 中用 const + iota 或 const + string 想要达到但无法被编译器严格检查的效果——Go 中一个 type Status string 类型的变量仍然可以接收任何字符串值,因为 string 是它的底层类型。
直观理解图
普通类型 (number) 字面量联合类型
┌──────┐ ┌──────┐
│ 1 │ │ 'GET'│
│ 42 │ ├──────┤
│ 3.14│ ← 无穷集合 │'POST'│ ← 有限集合
│ -1 │ ├──────┤
│ 100 │ │'PUT' │
│ ... │ ├──────┤
└──────┘ │'DEL' │
└──────┘
范围太宽 范围精确
无法做精确匹配 可以做穷举检查Q2:字面量类型与字面量本身的类型都是一个类型吗?
不是同一个类型,但存在子类型关系。
这是初学者最容易混淆的地方。看这个例子:
const x = 42
// x 的值:42
// x 的类型:number(更精确地说,用 const 声明时 TS 推断为字面量类型 42)事实上,const 声明让 TS 做了更精确的推断:
const x = 42 // x 的类型是 42(字面量类型),而不是 number
let y = 42 // y 的类型是 number
let z: 42 = 42 // z 的类型是 42(字面量类型)类型层级关系
42(字面量类型)是 number(宽泛类型)的子类型。意味着任何能用 number 的地方,也可以用 42:
const x = 42 // x: 42
let n: number = x // OK:42 是 number 的子类型
type T1 = 42 extends number ? true : false // true
type T2 = number extends 42 ? true : false // false反过来不行:
let a: 42 = 42 // OK
// a = 100 // Error:100 不是类型 42一个关键实验说明两者的关系
// 实验 1:值 'hello' 的类型是什么?
const greeting = 'hello'
// greeting 的类型是 'hello'(字面量类型),不是 string
// 实验 2:显式注解
const greeting2: string = 'hello'
// greeting2 的类型是 string(宽泛类型),不是 'hello'
// 实验 3:比较
type Check1 = 'hello' extends string ? true : false // true ✅
type Check2 = string extends 'hello' ? true : false // false ❌
// 结论:'hello'(字面量类型)是 string 的子类型
// string 不是 'hello' 的子类型为什么要有字面量类型和宽泛类型的区分?
这就像 Go 中的具体类型和其底层类型的关系:
// Go 中
type MyInt int // MyInt 和 int 是不同的类型
var a MyInt = 42
// var b int = a // Error:类型不匹配!
// TypeScript 中
type MyIntAlias = number // 这是类型别名,不是新类型
// number 和 42 的关系:
// 42 ≤ number(子类型关系)
// 可以用 42 赋值给 number,但不能反向但在 TypeScript 中,字面量类型和宽泛类型之间是子类型关系(subtype)而非不同种类的关系(nominal distinction),这比 Go 的 type MyInt int 更灵活——字面量类型天然可赋值给宽泛类型。
一张表总结
| 表达式 | 推断/注解的类型 | 可赋的值 |
|---|---|---|
const x = 42 | 42(字面量) | 只有 42 |
let x: 42 = 42 | 42(字面量) | 只有 42 |
let x = 42 | number(宽泛) | 任何 number |
let x: number = 42 | number(宽泛) | 任何 number |
let x: 42 | 43 = 42 | 42 | 43(联合) | 仅 42 或 43 |
实用提示
// 当你想要"精确控制"时,用字面量类型
type Port = 3000 | 8080 | 443
const startServer = (port: Port) => {}
startServer(3000) // OK
// startServer(4000) // Error
// 当你想要"灵活"时,用宽泛类型
type PortRange = number
const startServer2 = (port: PortRange) => {}
startServer2(4000) // OK,任何数字都可以字面量类型和其对应的宽泛类型的关系,可以理解为"具体的值"与"值的集合"的关系——42 是集合 number 中的一个元素,因此 42(单例集合)是 number(大集合)的子集。在类型层面,这就表现为子类型关系。
Q3:用 | 拼接枚举类型后,变量到底是一个值还是多个值?
两种场景,两种答案。
| 场景 | 运算符 | 变量的值 | 含义 |
|---|---|---|---|
| 类型联合(字面量联合类型) | |(联合类型符号) | 一个值,类型约束为多个中的某一个 | 变量是"其中之一" |
| 位标志(数值枚举) | |(位或运算符) | 一个值,由位运算组合产生 | 变量"聚合了多个标志" |
场景一:字面量联合类型(联合类型符号)
type Status = 'active' | 'inactive' | 'pending'
const s: Status = 'active' // s 的值是 'active',字面字符串此时 s 只能同时持有一个值。'active' | 'inactive' | 'pending' 描述的是值域(可以取哪些值),而非变量的实际内容。在运行时,s 就是 'active' 这个字符串。
一句话:联合类型是类型层面的约束,不是运行时的组合。变量装的是单个值,TS 编译器保证这个值属于联合中的一个。
场景二:数值枚举位标志(位或运算符)
enum Permission {
Read = 1 << 0, // 1
Write = 1 << 1, // 2
Execute = 1 << 2, // 4
}
const perm = Permission.Read | Permission.Write // perm 的值是 3此时 perm 的值是 3——一个单一的整数。这个整数的二进制位编码隐含了"同时包含 Read 和 Write"的信息。但你无法直接从 3 这个数本身看出它包含哪些标志——必须通过 & 位运算来解码。
一句话:位标志变量仍是单一数字值,但其二进制位的排列**编码了"包含多个标志"**的信息。这是将"多值信息"压缩到一个值中的编码技巧,并非字面意义上的"一个变量存储了多个值"。
为什么容易混淆?
因为 | 符号在两种场景中都出现了,但其语义完全不同:
// >>> 场景一:联合类型符号(纯类型层面) <<<
type Day = 'mon' | 'tue' | 'wed'
const d: Day = 'mon'
// d 的值:'mon'(一个字符串)
// 编译后:const d = 'mon'(类型信息完全擦除)
// >>> 场景二:位或运算符(运行时执行) <<<
enum Flag { A = 1, B = 2, C = 4 }
const f = Flag.A | Flag.B
// f 的值:3(一个数字,由位运算产生)
// 编译后:var Flag; (function(Flag) { ... })(Flag || {});
// const f = Flag.A | Flag.B 仍在运行时执行对照表:
| 维度 | 联合类型 A | B | 位标志 A | B |
|---|---|---|
| 运算级别 | 编译期(类型系统) | 运行时(位运算) |
| 结果类型 | 类型 A | B | 数字值(number) |
| 结果值 | A 或 B 之一 | 位运算后的单一数字 |
| 能否编码多值 | 否——只能"或" | 是——一个数字可编码多个标志 |
| 编译结果 | 完全擦除 | 保留为 JS 位运算代码 |
| 是否保留原来值的信息 | 是——变量就是原值 | 否——组合后的数字不可逆推原始值 |