引子
在使用Type Script + NodeJs作为技术栈开发一个后端service过程中(其实重头戏是fp-ts,但它和今天的主题无关),遇到一个有些有趣的小问题,遂记录如下。
需求很简单:用ts的type字段去定义一个domain modal(建模);后续在json序列化以后直接在endpoint里return出来。这可能在所有OOP的编程语言中都是第一步需要做的事(为了避免泄露业务代码,所有代码都被简化和代替了):
type Fruit = {
fruitName: FruitName; // response里水果名字可能是apple,banana或orange的三选一,类型为string
price: number;
};
const res: Fruit = {
fruitName: "apple", // 需求是在这里限定只能三种水果
price: 3
};
...
response.status(200).json(res)
...
在构造fruitName的type(FruitName)的时候, 直接跳进大脑里的可能就是enum
和literal type
(毕竟显然水果名字是有限集里的枚举类型):
type Fruit = {
fruitName: FruitName;
price: number;
};
// enum写法
enum FruitName {
APPLE = "apple",
BANANA = "banana",
ORANGE = "orange"
}
// literal type写法
type FruitName = "apple" | "banana" | "orange"
当然在实际引用的时候,方法也有所不同:
// enum
const res: Fruit = {
fruitName: FruitName.APPLE,
price: 3
};
// literal type
const res: Fruit = {
fruitName: "apple", // 如果这里是"pineapple"的话,ts会编译失败(废话
price: 3
};
很难说哪种引用方式更简洁,或者明晰。但在现代IDE的加持下,两种方式似乎都可以实现类型安全,以及编写代码实现时的良好提示体验。
那么是否enum
和literal type
没有任何区别呢?这种需求下是否并不存在best practice呢(我一度认为literal type更好。仅仅只是因为生产代码的枚举类型很多,这样可以少写几行代码,节省我的键盘耐久XD)?在我和同事们探索后,有了意想不到的发现。
编译差异
众所周知,TS仅仅在编译时生效,runtime的时候已经只剩pure的js代码了。那么,上述两种实现编译后的代码是否相同呢?
enum:
enum FruitName {
APPLE = "apple",
BANANA = "banana",
ORANGE = "orange"
}
const a: FruitName = FruitName.APPLE
// 编译后
var FruitName;
(function (FruitName) {
FruitName["APPLE"] = "apple";
FruitName["BANANA"] = "banana";
FruitName["ORANGE"] = "orange";
})(FruitName || (FruitName = {}));
const a = FruitName.APPLE;
literal type:
type FruitName = "apple" | "banana" | "orange"
const b: FruitName = "banana"
// 编译后
// ...是的,这里啥也没有
const b = "banana" // 编译后,literal type的概念不复存在
不难看出,TypeScript编译器对于enum和literal type的实现机制是完全不同的:
enum会被编译成function + key-value的方式,实现通过key调用值(不给enum值的话,默认value是0,1,2的number);而literal type是一个纯粹runtime之前的概念。在编译之后,b
将是一个纯粹的string
这个消息也许是利好literal type的。毕竟我们只需要在编译时保证类型安全,确保我们不会写出b = "pineapple"
这样的代码。enum的编译后文件可读性显然差很多,而使用literal type可以让编译更简单,提高performance。
literal type的问题与解决方案
实际上,上面的literal type写法存在一个风险:如果我们有需求——在runtime列出所有可能的FruitName
,此时enum由于会被编译成function与键值对,显然是可以实现的;但上述的literal type无法做到这一点,因为它被编译后只剩plain string。
如何解决呢?不卖关子(其实是节约键盘耐久),观察以下代码:
export const FruitName = ['apple', 'banana', 'orange'] as const;
// FruitName: readonly ['apple', 'banana', 'orange']
type FruitName = typeof FruitName[number];
// type FruitName = "apple" | "banana" | "orange"
这种实现依赖于TS5.0支持的如下新特性,感兴趣可以在官网了解更多:
as const
语法- type name可以与val name重名(编译器处理存在于不同空间)
typeof
语法
由于const FruitName
会被正常编译,所以可以通过锚定它,在使用literal type的同时遍历或者获取所有可能的枚举类型。
const enum
如果你实在无法割舍enum,譬如习惯使用形如FruitName.APPLE
这样的调用方式(笔者个人认为这种调用确实可读性更强),但同时想让编译后的代码像literal type那样更无痕;实际上TS也提供了这样的feature,也就是const enum
。
注意下面的代码:
const enum FruitName { // 唯一区别是enum前加const关键字
APPLE = "apple",
BANANA = "banana",
ORANGE = "orange"
}
const c: FruitName = FruitName.ORANGE
// 编译后
// const enum本身不会被编译
const c = "orange" /* FruitName3.ORANGE */; // 这里的注释是编译器添加的
好奇的话,你可以在这里试试ts和编译后的js代码,或许可以加深印象。
总结(太长不看)
最后,我在项目中使用了const enum
,为了兼顾编译效率和调用便利。我和我的小伙伴还是习惯于Key.Value
的调用模式。
当然,有些问题即使是const enum
也无法规避,比如跨文件使用必须先import,在同一scope下重复声明同名enum不会报编译错误等等,所以我相信我们最后的选择也并不是标准答案。具体使用哪种方案,可能取决于你的团队习惯、代码风格、use case。
而且我这篇文章也很有可能在某个时间点过时。举个例子,假如有一天JS有了自己的enum,而微软因此对TS的enum做了某些breaking change的话……(暂时不太可能,截至发文ECMAScript的enum预案还处于stage 0)
真实世界的feature永远处于动态发展的过程中。工程师必须永远好奇,永远不满足于标准答案。
参考资料
- TypeScript enums: How do they work? What can they be used for?
- Difference between string enums and string literal types in TS
- Optimize Enums in TypeScript with "const"
- Proposal for ECMAScript enums
- Alternatives to TypeScript enum
- How to list the possible values of a string literal union type in TypeScript
- TypeScript Playground