前几天在 DEV Community 上看到 NickM 的一篇文章,讲 TypeScript 里 enum 和 const 对象加 as const 的取舍,我照着跑了一遍,这篇笔记记下我验证过的部分。起因是组里的老争论:共享组件库里 prop 的取值到底用 enum 还是字面量联合类型,吵了好几次都没结论,这次算是找到一个能跑的方案。
先说场景。同事在共享的 React 模块里把组件可接受的 prop 值从 enum 改成了 const 对象:
const ButtonVariant = {
Primary: 'primary',
Secondary: 'secondary',
} as const;
这样改之后,消费方在 JSX 里可以直接写字符串:
<Button variant="primary" />
不用额外 import 一个 enum 进来,写起来确实舒服。这一点我试了一下,体验上的差距是真实存在的——尤其是那种只在一个文件里用一次的组件调用,为了一个 variant 值去 import 一整个枚举定义,总觉得有点重。
但 NickM 的立场和我一致:enum 是 TypeScript 的内置语言特性,它就是一个声明「这里接受哪些值」的权威来源,搜索和重命名都方便。而 const 对象本质上是个对象,它可以塞表达式、计算值、甚至初始化副作用进去——也就是说它没有「只能声明取值列表」这个约束。一个没有约束的东西被当成唯一事实来源,迟早会有人往里塞不该塞的东西。这个判断可能有点保守,但我认。
问题在于两种风格总要共存:组件作者想用 enum 当权威来源,消费方想直接写字面量。直接把 prop 类型写成 string 是不行的,那就什么字符串都能塞进去了。NickM 给出的方案是一个类型工具,叫 SoftEnum,思路是同时接受枚举成员和对应的字面量值,但拒绝任意字符串。我照着实现跑了一遍,核心是用模板字符串类型从枚举成员类型里把底层的字面量值提取出来,再和原始枚举成员类型取并集:
type SoftEnum<E extends string | number> =
| E
| `${E}`;
实际写出来比这个稍微绕一点(要处理联合类型的分发),但骨架就是这样。跑一遍验证:
enum Variant {
Primary = 'primary',
Secondary = 'secondary',
}
type ButtonVariant = SoftEnum<Variant>;
const a: ButtonVariant = Variant.Primary; // OK
const b: ButtonVariant = 'primary'; // OK
const c: ButtonVariant = 'whatever'; // 报错
前两行都过,第三行被拦住,这正是想要的行为。组件作者继续拿 enum 当唯一事实来源,消费方想偷懒就直接写字面量,两边都不用让步。
有两个细节单独记一下。第一个是数值型 enum 也能用:如果枚举值是数字,SoftEnum 会接受 0、1 这样的数字字面量,但不会把数字转成字符串——也就是传 '0' 不会通过。我一开始没搞懂为什么要区分这个,后来才想明白,'0' 和 0 在运行时是两个值,类型系统替你守住这条线,比运行时才发现对不上要好。
第二个细节是它支持通过第二个泛型参数只放开部分枚举键:
type OnlyMain = SoftEnum<Variant, 'Primary' | 'Secondary'>;
这样某个特定组件只接受其中两三个值的时候,不用另起一个枚举,从现有的枚举里挑就行。💡 这个用法我之前没想到,这次是照着文章试了一下才意识到它解决了「枚举越滚越大、单个组件只想要一小截」的老问题。
老实说,模板字符串类型那一层我也没有完全吃透,${E} 在 E 是联合类型时的分发行为,我是翻了半天文档又打了几个断点式的测试才大概确认的。这里我也没完全搞懂全部原理,但上面几个用例在我这里是都能跑通的,行为和预期一致,这条记下来了,下次应该用得上。
FAQ 一条:有人可能会问,那干脆全用字面量联合类型不就完了。我的看法是字面量联合类型没有「定义处」,散落在各个使用点,重命名的时候要靠编辑器的全局替换,而 enum 有一个明确的声明位置。对一个多人共享的模块来说,这个声明位置本身就有价值。当然如果你的项目就是一个人写的小东西,这个优势确实不明显,这点我不抬杠。
划重点:第一,enum 和字面量不是二选一,SoftEnum 这种类型工具可以让两种风格共存,权威来源还是 enum;第二,数值型 enum 也适用,但数字和字符串字面量不会互相转换;第三,第二个泛型参数可以只放开部分键,解决枚举过大的问题;第四,模板字符串类型提取字面量值这个技巧本身值得单独学一遍,不止这一个用处。下次组里再吵起来,我至少有个能跑的方案摆出来。你可以照着在自己项目里定义一个枚举试一遍,尤其是那个「拒绝任意字符串」的用例,跑通了再决定要不要用。
