Typescript 枚举类型入门
typescript枚举类型入门
why use 枚举类型
- 减少魔法字符串,魔法数字,增加代码可读性,健壮性
- 快速进行逻辑判断和组合(利用枚举作为 flag,通过位运算实现)
ts 中枚举类型的坑点
熟悉 js 的同学都知道,枚举类型是 ts 中新增的一个类型,而标准 javascript 没有枚举类型的概念。这自然就埋下了几个隐患:
- 熟悉 js 的人理解、使用 ts 的枚举需要额外的认知成本
- ts 中枚举既是值,又是类型,这个设计导致了一定的认知成本
- ts 中枚举为了适应一些特殊场景,设计了很多不太兼容的行为,埋下了坑
- ts 一般都是对 js 已有类型,添加类型的拓展,和 js 的 runtime 是可以较好的一一映射的,但是枚举不行,javascript 里至少目前是没有 enum 类型的,(tc39 关于js 原生 enum 的提案一直处在 stage0)。这会导致 ts 编译成 js 时,enum 类型需要转换成其他的 js 类型,这里会导致 sideEffect 等问题,稍后会讨论这些问题
ts 里枚举的用法
看一个枚举使用例子
约束传入值/魔法字符串
declare function setState(val: STATUS): void
enum STATUS {
OPEN = 'OPEN',
CLOSE = 'CLOSE',
}
// 反转状态
const clickSwitch = (current: STATUS) => {
setState(current === STATUS.OPEN ? STATUS.CLOSE : STATUS.OPEN)
}
在线样例地址
我们声明了一个枚举类型STATUS
,表示开关的状态,然后写了一个clickSwitch
函数,根据当前的状态,反转状态。
clickSwitch
函数的入参current
,我们希望它的值是相对固定的 2 个值'OPEN'
和'CLOSE'
,我们希望通过枚举类型,来做一个类型约束,保证传入的state
的值,只能是'OPEN'
和'CLOSE'
。
直接写成下面这种形式,即可约束传入的值只能是枚举类型STATUS
的值
const clickSwitch = (current: STATUS) => {}
可以看到,对于约束的入参,直接传入字符串,也是不行的,这样就避免了魔法字符串的问题
clickSwitch(STATUS.OPEN) // ✅
clickSwitch('OPEN') // ❌ ,魔法字符串也不行
clickSwitch('un support value') // ❌
枚举既是类型又是变量
const clickSwitch = (current: STATUS) => {
// ^这里的STATUS是一个类型
setState(current === STATUS.OPEN ? STATUS.CLOSE : STATUS.OPEN)
// ^这里的STATUS是一个值
}
这里可以发现枚举类型STATUS
在函数参数调用的时候,放在current
后面,标明current
变量的类型,此时STATUS
是一个类型。
然后下一行直接把STATUS.OPEN
和变量current
进行===
单目运算,此时STATUS
是一个类似 object 的一个变量
这个例子表现了枚举类型一个最重要的特点,枚举既是一个类型,也是一个变量(实际的值)。可以拆解一个上面的例子为:
const STATUS = {
OPEN: 'OPEN',
CLOSE: 'CLOSE',
}
type STATUS_TYPE = 'OPEN' | 'CLOSE'
// 反转状态
const clickSwitch = (current: STATUS_TYPE) => {
setState(current === STATUS.OPEN ? STATUS.CLOSE : STATUS.OPEN)
}
这里我们把枚举类型拆成了一个变量名为STATUS
的 object,和STATUS_TYPE
的 type,而 ts 的 enum 类型,通过一个变量的方式,完成了两种声明,简化了这个流程。
数字枚举
枚举类型主要分 2 种,一种是枚举值是字符串的,就是上面的例子,一种是枚举值是数字的
declare function setState(val: STATUS): void
enum STATUS {
OPEN = 1,
CLOSE = 0,
}
// 反转状态
const clickSwitch = (current: STATUS) => {
// ^这里的STATUS是一个类型
setState(current === STATUS.OPEN ? STATUS.CLOSE : STATUS.OPEN)
// ^这里的STATUS是一个值
}
clickSwitch(STATUS.OPEN) // ✅
clickSwitch('un support value') // ❌
我们稍微修改了一下 上面的例子,把枚举的 value 换成了数字 0 和 1,其他没什么变化。看起来是正常 work 的。
但是我们试着传一些不预期的值,看看 ts 能否检查出错误
clickSwitch(1) // ✅ why?
clickSwitch(3) // ✅ why???, work as unexpected ????
传了一个数字 1 和数字 3 给clickSwitch
函数,发现 ts 好像都没报错。这不对呀,进一步比较一下入参和STATUS
的类型
type a2 = 1 extends STATUS ? true : false // true ,还算比较合理,毕竟STATUS的值里有 1
type a3 = number extends STATUS ? true : false // true ,why???
可以发现 number 类型是可以分配给(assignable to )STATUS
。原来这里是 ts 给 numeric enum 留了个后门,目的是方便位运算的类型兼容。举个 ????:
// 游戏场景
enum AttackType {
// Decimal // Binary
None = 0, // 000000
Melee = 1, // 000001
Fire = 2, // 000010
Ice = 4, // 000100
Poison = 8, // 001000
}
// 一个攻击,位运算:属性 近战 | 火 | 毒
const MeleeAndFire = AttackType.Melee | AttackType.Fire | AttackType.Poison
const attack = (attack: AttackType) => {}
// 这里 `MeleeAndFire` 可以分配给类型`AttackType`
attack(MeleeAndFire)
// 直接传入
attack(AttackType.Melee)
这里是一个游戏场景,如何叠加攻击效果呢?可以通过位运算进行叠加,叠加完成后输出的虽然是 number 类型,但是依然可以传入攻击函数attack
。如果类型设计成不兼容,会不太方便。
相关参考:
- 探讨数字枚举和 number 的类型关系的 issue:github.com/microsoft/T…
- c#的例子: www.alanzucconi.com/2015/07/26/…
- ts 的例子: juejin.cn/post/703373…
- ts 源码里面的例子:raw.githubusercontent.com/microsoft/T… (查找 enum TypeFacts)
所以如果只是想收敛类型,同时避免魔法字符串,最好还是用字符串枚举
用法 bad case 1:number 和 string 混着用
enum STATUS {
YES = 1,
NO = 0,
UNKNOWN = 'UNKNOWN',
}
ts 支持,但是尽量别用,有坑,最好改用别的方式进行替代
枚举的类型变换(ts 类型体操部分)
提取枚举的值
假设我们有一个枚举类型CHAR
,我们想要获取它值的并集,比如 'A' | 'B'
,我们可以这样做
enum CHAR {
A = 'A',
B = 'B',
}
type values = `${CHAR}`
为什么可以这样做?回忆一下上文讲的,enum 既是一个类型,也是一个值。CHAR
的等价类型就是'A' | 'B'
转换成下面的方式是不是就理解了
type CHAR_UNION = 'A' | 'B'
type valuesU = `${CHAR_UNION}`
有同学可能会问CHAR
类型不就是'A' | 'B'
吗?基本没错,虽然可以这样理解,但是因为枚举的原因,CHAR
和'A' | 'B'
并不能完全等价,因此需要这一段转换。(ts 类型体操特色)
枚举和泛型
枚举约束
常常会有一些同学提出一些需求,比如想要获取一个泛型工具函数,约束枚举:
下面伪代码,实际 ts 环境跑不起来
export enum SortOrder {
Default,
High,
Medium,
Low,
}
export interface Utils<T extends enum> {
sortOrder: T
}
type newType = Utils<SortOrder>
很遗憾,现在目前 ts 是没有这个实现的。
获取枚举本身的类型
假设一个场景,通过 http 接口获取了一个参数,这个参数应该是一个枚举值,假设我们要保证这个枚举值一定是规定好的某个值,如果不是,我们传入一个默认的参数
大致想要的效果如下:
enum AbTest {
A = 'A',
B = 'B',
DEFAULT = 'DEFAULT',
}
const guardValue = (val, enumVal, defaultValue) => {
return Object.values(enumVal).includes(val) ? val : defaultValue
}
//实际调用
guardValue('不可控来源传入的值', AbTest, AbTest.DEFAULT)
其中函数的三个入参,val
是接口传的值,enumVal
是传入的枚举,defaultValue
是兜底的默认值。
接下来我们尝试把这个函数的入参的类型补全:
const guardValue = (
val: string,
enumVal: AbTest /** 这里改写啥?AbTest好像不对 */,
defaultValue: AbTest
) => {
return Object.values(enumVal).includes(val) ? val : defaultValue
}
然后就会发现enumVal
好像怎么写的类型不太对,如果要写AbTest
,那不是和defaultValue
的类型区分不开吗?仔细思考一下,enumVal
期望的是一个类似 object 的类型,而之前上文提到枚举类型既是值(类似 object),又是类型(类似 union)。这里期望的是获取枚举作为一个值的类型,ts 中获取值的类型的方式,不正是typeof
吗?
于是,我们可以这样写
const guardValue2 = (
val: string,
enumVal: typeof AbTest, // 这里 `typeof 枚举类型` 获取的就是 枚举类型作为值的type
defaultValue: AbTest
) => {
const typedVal = val as AbTest
return Object.values(enumVal).includes(typedVal) ? typedVal : defaultValue
}
这里typeof AbTest
的类型基本就等同于下面这个类型:
type AbTestType = {
A: 'A'
B: 'B'
DEFAULT: 'DEFAULT'
}
看起来就是一个 object 的类型
如果我们想延伸一下让guardValue
函数成为一个泛型函数,支持传入任意的枚举值,会遇到 2 个问题:
-
enumVal
有一个枚举的约束,类似T extends enum
,保证enumVal
传入的类型都是枚举类型 -
enumVal
和defaultValue
有个泛型约束关系
解法:
-
目前已知 1 是 ts 本身没有支持的了,那么我们怎么来约束这个枚举类型呢?结论很简单:就用 object 的模式来约束即可:
Record<string,string,number>
-
把枚举类型想象成一个 object,
defaultValue
是 object 的值,约束一个 object 的值和 object 就变的简单起来了:
enum AbTest {
A = 'A',
B = 'B',
DEFAULT = 'DEFAULT',
}
type AbTestEnumType = typeof AbTest
type AbTest2 = AbTestEnumType[keyof AbTestEnumType]
// 这里type AbTest2 === type AbTest
最后我们可以写出这样一个函数,基本满足了需求
export function guardValue3<
E extends Record<string, string | number>,
P extends E[keyof E] // E 这里就是(typeof AbTest)
>(val: string | number, enumVal: E, defaultValue: P) {
return Object.values(enumVal).includes(val as E[keyof E])
? (val as E[keyof E])
: defaultValue
}
const value3 = guardValue3('不可控来源传入的值', AbTest, AbTest.DEFAULT)
const value4 = guardValue3(AbTest.A, AbTest, AbTest.DEFAULT)
runtime 和枚举类型带来的隐患
到目前为止,我们还没有谈到 runtime,为什么要谈 runtime,因为之前说了 enum 在 js 中是没有的,ts 要编译成 js,就需要转换。我们看看 ts 转换 enum 成了什么:
enum Color {
RED = 'red',
BLUE = 'blue',
}
对应 js
var Color
;(function (Color) {
Color['RED'] = 'red'
Color['BLUE'] = 'blue'
})(Color || (Color = {}))
export { Color }
可以看到Color
变成了一个 object,这和之前说的枚举类型Color
作为一个值的时候,相等于一个 object 是对应的。但是这样编译有个问题,枚举类型在实际 runtime 中编译成了一个立即执行函数(IIFE)。如果是普通业务,自己的系统内部不会有什么问题。但如果这是一个 ts 写 npm 库,需要提供给别人调用,就会发现因为枚举类型变成了立即执行函数(IIFE),无法被 tree shaking 优化掉,因为这个 IIFE 有副作用。
举个例子,你写了一个库,打包好了,里面可以导出很多方法和变量,你把你的库给调用方用,调用方只 import 了一个方法,调用方最后打包的产物却把你的库里,所有没有引用的枚举立即执行函数全部打包进去了,包体积便无意义的增大了。如果你的库比较大,这个影响可能会比较头疼。
熟悉 tree shaking 的同学可能会问,为什么 tsc 不这样编译呢:
var Color = /* @__PURE__ */ ((Color2) => {
Color2['RED'] = 'red'
Color2['BLUE'] = 'blue'
return Color2
})(Color || {})
export { Color }
这样就能明确告知各个打包工具,这个立即执行函数是无副作用的,外面没用Color
的话,都可以删了
用法 bad case2:ts 的 enum 是可以多次补充的
因为 ts 的 enum 是可以多次补充的,看个很简单的例子:
export enum Color {
RED = 'red',
}
export enum Color {
BLUE = 'blue',
}
export var Color
;(function (Color) {
Color['RED'] = 'red'
})(Color || (Color = {}))
;(function (Color) {
Color['BLUE'] = 'blue'
})(Color || (Color = {}))
在线例子
因为Color
可以被不断补充,编译的时候需要把 Color 放在立即执行函数外面,自然就不能被优化成__PURE__
,因为存在 enum 确实有副作用的可能性 ????。
那有没有办法能既想用枚举,也想保持包能被裁减的功能呢?
社区里也有人提出了这个问题,可以看到 社区里面为了消除这个副作用,甚至直接放弃了使用枚举类型。比如这个库:vueuse
解决办法 1:使用 babel 插件
社区有开发者写了babel 插件来解决这个问题,这个笔者没有测试用过,可以看看
解决办法 2:使用 esbuild
ts,swc 和 terser 也有开发者提这个问题,但是官方并没有解决这个问题。目前 esbuild 可以,可以看看 esbuild 作者的留言
包括 enum 拓展声明也可以优化成无副作用代码,比如这个例子
这个笔者试过,esbuild确实能把枚举优化成下面的样子:
var Color = /* @__PURE__ */ ((Color2) => {
Color2['RED'] = 'red'
Color2['BLUE'] = 'blue'
return Color2
})(Color || {})
export { Color }
解决办法 3:另辟蹊径
枚举类型实际 runtime 就是个object
,那不用枚举,直接用ReadOnly<object>
不也可以吗?
const ColorEnum = {
Green: 'GreenValue',
/** 蓝色 */
Blue: 'BlueValue',
/** 红色 */
Red: 'RedValue',
} as const
// 轻松抽取key
type ColorEnumKeys = keyof typeof ColorEnum
// ^ = "Green" | "Blue" | "Red"
// 轻松抽取value
type ColorEnumValues = (typeof ColorEnum)[keyof typeof ColorEnum]
// ^ = "GreenValue" | "BlueValue" | "RedValue"
其中ColorEnum
就是相等于枚举类型的值部分,ColorEnumValues
就是相等于枚举类型的类型部分,正好完美替代。
as const
把ColorEnum
声明成一个 readonly 的 object,保证枚举类型不会被改写,ColorEnum
的类型是
const ColorEnum: {
readonly Green: 'GreenValue'
readonly Blue: 'BlueValue'
readonly Red: 'RedValue'
}
更简单的替代方式是直接使用 value 的 union 进行替代,比如'GreenValue'|'BlueValue'|'RedValue'
需要自己去平衡和取舍了
解决办法 4:可能的另一种选择 const enum
简单来说就是 const enum 可以在编译后被擦除,enum 作为 object 的功能不存在了,比如
const enum Constants {
DefaultName = 'foo',
DefaultTimeout = '1000',
}
const a = Constants.DefaultName
实际编译后是:
const a = 'foo' /* Constants.DefaultName */
可以看到整个 object 被编译时抹掉了。如果你用不上枚举作为 object 的值,可以这样做,对 runtime 来说是基本无影响的。
所以这么写是会报错的,因为Constants
没法作为 object 使用
const enum Constants {
DefaultName = 'foo',
DefaultTimeout = '1000',
}
Object.values(Constants) // ❌ , ts(2475)
另一个点是既然没有 object 了,打包到 runtime,export 的就是一个空了。
export const enum Constants {
DefaultName = 'foo',
DefaultTimeout = '1000',
}
export {}
如果你是一个 npm 包开发者,得考虑这个枚举要不要导出给调用方去使用。
tsconfig 有一个preserveConstEnums
选项,可以阻止擦除 object,但是编译后,引用枚举值的地方还是变成了字符串。
export const enum Constants {
DefaultName = 'foo',
DefaultTimeout = '1000',
}
const a = Constants.DefaultName
preserveConstEnums
= true
,实际编译后是:
export var Constants
;(function (Constants) {
Constants['DefaultName'] = 'foo'
Constants['DefaultTimeout'] = '1000'
})(Constants || (Constants = {}))
const a = 'foo' /* Constants.DefaultName */
object 被保留了,但是并不会引用它。
枚举的最佳实践
还是那句话,业务实践没有银弹(Silver bullet
),适合自己业务的,就是最好的
-
如果业务场景里不在意 ts 在处理
enum
时,生成的IIFE
的Side Effect
,直接使用enum
就好 -
如果业务场景不需要使用
enum
的object
的值,只需要用到它的value
值,使用const enum
更好 -
如果业务场景需要使用
enum
的object
的值,又不想要Side Effect
的干扰,直接使用object as const
来替代枚举类型,使得编译出最纯净的 js,或者使用上文介绍的其他方法,消除副作用
附录部分:展望未来,枚举优化(ts5.0相关mr)
合并数字枚举和字符串枚举的不一致行为
字符串枚举和数字枚举类型在ts底层合并成同一种类型,统一行为,修复一些 bug。同时模板字符串也能优化成常量
const bar = (add: number) => {
return add + 1
}
const str = (str: string) => {
return str + 'n'
}
const NAMESPACE = 'com.mycompany.myservice'
enum E {
A = 10 * 10, // 能被优化成常量
B = 'foo', // 能被优化成常量
C = bar(42), // 数字计算属性,可以保留函数
D = str('a'), // 字符串函数不能被优化
INVALID_INPUT_ERROR = `${NAMESPACE}.errors#InvalidInput`, // 能被优化成常量,这里过去是不支持的
}
runtime产物
const bar = (add) => {
return add + 1
}
const str = (str) => {
return str + 'n'
}
const NAMESPACE = 'com.mycompany.myservice'
var E
;(function (E) {
E[(E['A'] = 100)] = 'A'
E['B'] = 'foo'
E[(E['C'] = bar(42))] = 'C'
E[(E['D'] = str('a'))] = 'D'
E['INVALID_INPUT_ERROR'] = 'com.mycompany.myservice.errors#InvalidInput' // 能被优化成常量
})(E || (E = {}))
在线playground
上一篇: 如何在前端优雅地使用枚举
下一篇: 枚举:程序设计中的应用和优势