likes
comments
collection
share

typescript枚举类型入门

作者站长头像
站长
· 阅读数 21

typescript枚举类型入门

why use 枚举类型

  1. 减少魔法字符串,魔法数字,增加代码可读性,健壮性
  2. 快速进行逻辑判断和组合(利用枚举作为 flag,通过位运算实现)

ts 中枚举类型的坑点

熟悉 js 的同学都知道,枚举类型是 ts 中新增的一个类型,而标准 javascript 没有枚举类型的概念。这自然就埋下了几个隐患:

  1. 熟悉 js 的人理解、使用 ts 的枚举需要额外的认知成本
  2. ts 中枚举既是值,又是类型,这个设计导致了一定的认知成本
  3. ts 中枚举为了适应一些特殊场景,设计了很多不太兼容的行为,埋下了坑
  4. 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。如果类型设计成不兼容,会不太方便。

相关参考:

所以如果只是想收敛类型,同时避免魔法字符串,最好还是用字符串枚举

用法 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 个问题:

  1. enumVal有一个枚举的约束,类似T extends enum,保证enumVal传入的类型都是枚举类型
  2. enumValdefaultValue有个泛型约束关系

解法:

  1. 目前已知 1 是 ts 本身没有支持的了,那么我们怎么来约束这个枚举类型呢?结论很简单:就用 object 的模式来约束即可:Record<string,string,number>

  2. 把枚举类型想象成一个 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 constColorEnum声明成一个 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),适合自己业务的,就是最好的

  1. 如果业务场景里不在意 ts 在处理enum时,生成的IIFESide Effect,直接使用enum就好

  2. 如果业务场景不需要使用enumobject的值,只需要用到它的value值,使用const enum更好

  3. 如果业务场景需要使用enumobject的值,又不想要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

转载自:https://juejin.cn/post/7205875448567808059
评论
请登录