13. TypeScript 的 Enum 类型
Enum 是 TypeScript 新增的一种数据结构和类型,中文译为“枚举”。
简介
实际开发中,经常需要定义一组相关的常量。
const RED = 1;
const GREEN = 2;
const BLUE = 3;
let color = userInput();
if (color === RED) {/* */}
if (color === GREEN) {/* */}
if (color === BLUE) {/* */}
throw new Error('wrong color');
上面示例中,常量RED
、GREEN
、BLUE
是相关的,意为变量color
的三个可能的取值。它们具体等于什么值其实并不重要,只要不相等就可以了。
TypeScript 就设计了 Enum 结构,用来将相关常量放在一个容器里面,方便使用。
上面示例声明了一个 Enum 结构Color
,里面包含三个成员Red
、Green
和Blue
。第一个成员的值默认为整数0
,第二个为1
,第三个为2
,以此类推。
使用时,调用 Enum 的某个成员,与调用对象属性的写法一样,可以使用点运算符,也可以使用方括号运算符。
Enum 结构本身也是一种类型。比如,上例的变量c
等于1
,它的类型可以是 Color,也可以是number
。
上面示例中,变量c
的类型写成Color
或number
都可以。但是,Color
类型的语义更好。
Enum 结构的特别之处在于,它既是一种类型,也是一个值。绝大多数 TypeScript 语法都是类型语法,编译后会全部去除,但是 Enum 结构是一个值,编译后会变成 JavaScript 对象,留在代码中。
// 编译前
enum Color {
Red, // 0
Green, // 1
Blue // 2
}
// 编译后
let Color = {
Red: 0,
Green: 1,
Blue: 2
};
上面示例是 Enum 结构编译前后的对比。
由于 TypeScript 的定位是 JavaScript 语言的类型增强,所以官方建议谨慎使用 Enum 结构,因为它不仅仅是类型,还会为编译后的代码加入一个对象。
Enum 结构比较适合的场景是,成员的值不重要,名字更重要,从而增加代码的可读性和可维护性。
enum Operator {
ADD,
DIV,
MUL,
SUB
}
function compute(
op:Operator,
a:number,
b:number
) {
switch (op) {
case Operator.ADD:
return a + b;
case Operator.DIV:
return a / b;
case Operator.MUL:
return a * b;
case Operator.SUB:
return a - b;
default:
throw new Error('wrong operator');
}
}
compute(Operator.ADD, 1, 3) // 4
上面示例中,Enum 结构Operator
的四个成员表示四则运算“加减乘除”。代码根本不需要用到这四个成员的值,只用成员名就够了。
TypeScript 5.0 之前,Enum 有一个 Bug,就是 Enum 类型的变量可以赋值为任何数值。
上面示例中,函数foo
的参数noYes
是 Enum 类型,只有两个可用的值。但是,TypeScript 5.0 之前,任何数值作为函数foo
的参数,编译都不会报错,TypeScript 5.0 纠正了这个问题。
另外,由于 Enum 结构编译后是一个对象,所以不能有与它同名的变量(包括对象、函数、类等)。
上面示例,Enum 结构与变量同名,导致报错。
很大程度上,Enum 结构可以被对象的as const
断言替代。
enum Foo {
A,
B,
C,
}
const Bar = {
A: 0,
B: 1,
C: 2,
} as const;
if (x === Foo.A) {}
// 等同于
if (x === Bar.A) {}
上面示例中,对象Bar
使用了as const
断言,作用就是使得它的属性无法修改。这样的话,Foo
和Bar
的行为就很类似了,前者完全可以用后者替代,而且后者还是 JavaScript 的原生数据结构。
Enum 成员的值
Enum 成员默认不必赋值,系统会从零开始逐一递增,按照顺序为每个成员赋值,比如0、1、2……
但是,也可以为 Enum 成员显式赋值。
上面示例中,Enum 每个成员的值都是显式赋值。
成员的值可以是任意数值,但不能是大整数(Bigint)。
上面示例中,Enum 成员的值可以是小数,但不能是 Bigint。
成员的值甚至可以相同。
如果只设定第一个成员的值,后面成员的值就会从这个值开始递增。
Enum 成员的值也可以使用计算式。
enum Permission {
UserRead = 1 << 8,
UserWrite = 1 << 7,
UserExecute = 1 << 6,
GroupRead = 1 << 5,
GroupWrite = 1 << 4,
GroupExecute = 1 << 3,
AllRead = 1 << 2,
AllWrite = 1 << 1,
AllExecute = 1 << 0,
}
enum Bool {
No = 123,
Yes = Math.random(),
}
上面示例中,Enum 成员的值等于一个计算式,或者等于函数的返回值,都是正确的。
Enum 成员值都是只读的,不能重新赋值。
上面示例中,重新为 Enum 成员赋值就会报错。
为了让这一点更醒目,通常会在 enum 关键字前面加上const
修饰,表示这是常量,不能再次赋值。
加上const
还有一个好处,就是编译为 JavaScript 代码后,代码中 Enum 成员会被替换成对应的值,这样能提高性能表现。
const enum Color {
Red,
Green,
Blue
}
const x = Color.Red;
const y = Color.Green;
const z = Color.Blue;
// 编译后
const x = 0 /* Color.Red */;
const y = 1 /* Color.Green */;
const z = 2 /* Color.Blue */;
上面示例中,由于 Enum 结构前面加了const
关键字,所以编译产物里面就没有生成对应的对象,而是把所有 Enum 成员出现的场合,都替换成对应的常量。
如果希望加上const
关键词后,运行时还能访问 Enum 结构(即编译后依然将 Enum 转成对象),需要在编译时打开preserveConstEnums
编译选项。
同名 Enum 的合并
多个同名的 Enum 结构会自动合并。
上面示例中,Foo
分成三段定义,系统会自动把它们合并。
Enum 结构合并时,只允许其中一个的首成员省略初始值,否则报错。
上面示例中,Foo
的两段定义的第一个成员,都没有设置初始值,导致报错。
同名 Enum 合并时,不能有同名成员,否则报错。
上面示例中,Foo
的两段定义有一个同名成员B
,导致报错。
同名 Enum 合并的另一个限制是,所有定义必须同为 const 枚举或者非 const 枚举,不允许混合使用。
// 正确
enum E {
A,
}
enum E {
B = 1,
}
// 正确
const enum E {
A,
}
const enum E {
B = 1,
}
// 报错
enum E {
A,
}
const enum E {
B = 1,
}
同名 Enum 的合并,最大用处就是补充外部定义的 Enum 结构。
字符串 Enum
Enum 成员的值除了设为数值,还可以设为字符串。也就是说,Enum 也可以用作一组相关字符串的集合。
上面示例中,Direction
就是字符串枚举,每个成员的值都是字符串。
注意,字符串枚举的所有成员值,都必须显式设置。如果没有设置,成员值默认为数值,且位置必须在字符串成员之前。
上面示例中,A
之前没有其他成员,所以可以不设置初始值,默认等于0
;C
之前有一个字符串成员,所以C
必须有初始值,不赋值就报错了。
Enum 成员可以是字符串和数值混合赋值。
除了数值和字符串,Enum 成员不允许使用其他值(比如 Symbol 值)。
变量类型如果是字符串 Enum,就不能再赋值为字符串,这跟数值 Enum 不一样。
上面示例中,变量s
的类型是MyEnum
,再赋值为字符串就报错。
由于这个原因,如果函数的参数类型是字符串 Enum,传参时就不能直接传入字符串,而要传入 Enum 成员。
enum MyEnum {
One = 'One',
Two = 'Two',
}
function f(arg:MyEnum) {
return 'arg is ' + arg;
}
f('One') // 报错
上面示例中,参数类型是MyEnum
,直接传入字符串会报错。
所以,字符串 Enum 作为一种类型,有限定函数参数的作用。
前面说过,数值 Enum 的成员值往往不重要。但是有些场合,开发者可能希望 Enum 成员值可以保存一些有用的信息,所以 TypeScript 才设计了字符串 Enum。
const enum MediaTypes {
JSON = 'application/json',
XML = 'application/xml',
}
const url = 'localhost';
fetch(url, {
headers: {
Accept: MediaTypes.JSON,
},
}).then(response => {
// ...
});
上面示例中,函数fetch()
的参数对象的属性Accept
,只能接受一些指定的字符串。这时就很适合把字符串放进一个 Enum 结构,通过成员值来引用这些字符串。
字符串 Enum 可以使用联合类型(union)代替。
上面示例中,函数参数where
属于联合类型,效果跟指定为字符串 Enum 是一样的。
注意,字符串 Enum 的成员值,不能使用表达式赋值。
上面示例中,成员B
的值是一个字符串表达式,导致报错。
keyof 运算符
keyof 运算符可以取出 Enum 结构的所有成员名,作为联合类型返回。
上面示例中,keyof typeof MyEnum
可以取出MyEnum
的所有成员名,所以类型Foo
等同于联合类型'A'|'B'
。
注意,这里的typeof
是必需的,否则keyof MyEnum
相当于keyof string
。
type Foo = keyof MyEnum;
// number | typeof Symbol.iterator | "toString" | "charAt" | "charCodeAt" | ...
上面示例中,类型Foo
等于类型string
的所有原生属性名组成的联合类型。这是MyEnum
为字符串 Enum 的结果,如果MyEnum
是数值 Enum,那么keyof MyEnum
相当于keyof number
。
这是因为 Enum 作为类型,本质上属于number
或string
的一种变体,而typeof MyEnum
会将MyEnum
当作一个值处理,从而先其转为对象类型,就可以再用keyof
运算符返回该对象的所有属性名。
如果要返回 Enum 所有的成员值,可以使用in
运算符。
上面示例中,采用属性索引可以取出MyEnum
的所有成员值。
反向映射
数值 Enum 存在反向映射,即可以通过成员值获得成员名。
enum Weekdays {
Monday = 1,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
}
console.log(Weekdays[3]) // Wednesday
上面示例中,Enum 成员Wednesday
的值等于3,从而可以从成员值3
取到对应的成员名Wednesday
,这就叫反向映射。
这是因为 TypeScript 会将上面的 Enum 结构,编译成下面的 JavaScript 代码。
var Weekdays;
(function (Weekdays) {
Weekdays[Weekdays["Monday"] = 1] = "Monday";
Weekdays[Weekdays["Tuesday"] = 2] = "Tuesday";
Weekdays[Weekdays["Wednesday"] = 3] = "Wednesday";
Weekdays[Weekdays["Thursday"] = 4] = "Thursday";
Weekdays[Weekdays["Friday"] = 5] = "Friday";
Weekdays[Weekdays["Saturday"] = 6] = "Saturday";
Weekdays[Weekdays["Sunday"] = 7] = "Sunday";
})(Weekdays || (Weekdays = {}));
上面代码中,实际进行了两组赋值,以第一个成员为例。
上面代码有两个赋值运算符(=
),实际上等同于下面的代码。
注意,这种情况只发生在数值 Enum,对于字符串 Enum,不存在反向映射。这是因为字符串 Enum 编译后只有一组赋值。