用 const enum 和字符串值类型保留公开 API 的自然写法
最近整理一个通用弹窗组件时,遇到一个很小但很顺手的类型写法:
export const enum DialogMode {
Alert = 'alert',
Confirm = 'confirm',
Prompt = 'prompt',
}
export type DialogModeValue = `${DialogMode}`这个写法的妙处不在于炫技,而是它刚好把两个经常混在一起的需求拆开了:组件内部需要有名字的枚举常量,组件外部需要自然的字符串 API。
以 DialogMode.Prompt 为例,组件内部读到这个名字,马上知道这是弹窗模式;模板或调用方写 mode="prompt",也符合 HTML / Vue props 的直觉。两边都舒服,类型系统还没有放松。
问题从公开 API 开始
很多 UI 组件都有类似的 prop:
<CommonDialog mode="prompt" />从调用方视角看,mode="prompt" 是最自然的写法。它像 HTML attribute,也像普通配置项。调用方不一定想为了一个字符串 prop 额外导入 DialogMode:
import { DialogMode } from './types'可是组件内部又不应该到处散落字符串:
const shouldShowInput = computed(() => props.mode === 'prompt')
const shouldShowCancel = computed(() => props.mode !== 'alert')这些字符串短期看很轻,后期读起来却会变成噪音。搜索 'prompt' 可能搜到文案、接口字段、测试数据;重命名时也不知道哪些是同一个概念。内部逻辑更适合写成这样:
const shouldShowInput = computed(() => props.mode === DialogMode.Prompt)
const shouldShowCancel = computed(() => props.mode !== DialogMode.Alert)这条边界更像一层分工:public API 和 internal implementation 各自拿到适合自己的形态。
只用字符串联合会缺少命名锚点
最普通的写法是直接定义字符串联合:
export type DialogMode = 'alert' | 'confirm' | 'prompt'它对公开 API 很友好:
interface DialogProps {
mode?: DialogMode
}
const props: DialogProps = {
mode: 'prompt',
}问题是内部代码没有一个稳定的命名常量来源。可以继续比较 'prompt',也可以再手写一份对象常量:
export const DialogMode = {
Alert: 'alert',
Confirm: 'confirm',
Prompt: 'prompt',
} as const
export type DialogMode = (typeof DialogMode)[keyof typeof DialogMode]这套 as const + typeof + keyof 本身没有错。TypeScript 官方文档在 Objects vs Enums 里也提到,现代 TypeScript 里有些场景可以用对象常量代替 enum。
但对一组纯字符串 UI 状态来说,这段写法有点重:同一个名字既是 const 又是 type,新人读到 DialogMode 时要停一下,判断现在看到的是值还是类型;如果项目里大量出现这类模式,读代码会被很多工具型模板打断。
只用 enum 会让调用方别扭
另一种写法是直接把 prop 类型写成 enum:
export const enum DialogMode {
Alert = 'alert',
Confirm = 'confirm',
Prompt = 'prompt',
}
interface DialogProps {
mode?: DialogMode
}内部代码变清楚了,调用方却变别扭了:
const props: DialogProps = {
mode: 'prompt',
// Type '"prompt"' is not assignable to type 'DialogMode'.
}TypeScript 的字符串 enum member 本身可以作为类型,整个 enum 类型也能被看作这些 member 的联合。TypeScript Handbook 的 Union enums 章节说明了这套语义:当 enum 的所有成员都是字面量成员时,类型系统知道这个 enum 只有哪些值。
这对内部封闭状态很合适。可是在组件 prop、JSON 配置、URL query、测试 fixture 这类公开字符串边界上,直接要求调用方传 DialogMode.Prompt,会把内部实现细节推到外面。API 本来只是要一个 'prompt',结果使用者还要关心这个字符串来自哪个 enum。
用 ${Enum} 拿到字符串值类型
DialogModeValue 解决的是这个缝隙。
export const enum DialogMode {
Alert = 'alert',
Confirm = 'confirm',
Prompt = 'prompt',
}
export type DialogModeValue = `${DialogMode}`这里的 DialogModeValue 会变成:
type DialogModeValue = 'alert' | 'confirm' | 'prompt'原因来自 TypeScript 的模板字面量类型。官方文档里写到,模板字面量类型可以基于字符串字面量类型构造新类型;如果插值位置里是联合类型,结果会展开成每个可能字符串的集合。
放到 enum 上就是:DialogMode 这个类型位置代表 enum 成员的联合,${DialogMode} 再把这些成员的字符串值投影成普通字符串字面量联合。
于是公开 prop 可以这样写:
interface DialogProps {
mode?: DialogModeValue
}调用方仍然可以直接传字符串:
const props: DialogProps = {
mode: 'prompt',
}组件内部也可以继续用 enum 常量:
const shouldShowInput = computed(() => props.mode === DialogMode.Prompt)
const shouldShowCancel = computed(() => props.mode !== DialogMode.Alert)这就是这套模式最有价值的地方:类型对外是字符串,对内是枚举命名;两者只维护一份值来源。
const enum 负责内部可读性
这里推荐 const enum,主要是因为这类 UI 字符串通常只是编译期常量,不需要运行时枚举对象。
TypeScript Handbook 的 const enum 章节说明,const enum 会在编译结果里被移除,enum member 会在使用处被内联。对应用代码来说,DialogMode.Prompt 既能让源码可读,又不必为了这一组常量额外生成一个运行时对象。
这也意味着它不适合所有情况。如果代码需要运行时遍历:
Object.values(DialogMode)那就不能用 const enum,因为运行时没有 DialogMode 这个对象。此时更适合用普通对象常量,或者保留普通 enum。
还有一个边界要特别注意:公开 npm 包或会输出 .d.ts 给外部项目消费的库,不要随手把 const enum 暴露成跨包契约。TypeScript 官方文档把 ambient const enum 的坑列得很清楚,典型风险包括版本 A 编译内联、运行时却加载版本 B,导致分支判断和测试环境不一致。应用项目内部使用,风险小很多;跨项目发布时,就要重新评估。
命名最好把两层语义写出来
我更喜欢把 enum 和字符串值类型分成两个名字:
export const enum DialogMode {
Alert = 'alert',
Confirm = 'confirm',
Prompt = 'prompt',
}
export type DialogModeValue = `${DialogMode}`DialogMode 表示“这是一组有业务名字的模式常量”。DialogModeValue 表示“公开 API 接收这些模式的字符串值”。
这个命名比复用同一个 DialogMode 更直观。读 DialogModeValue 时,读者能马上意识到这是 value 层,不是 enum member 层;读 DialogMode.Prompt 时,也知道它是内部命名锚点。
如果项目已经有更明确的领域名,可以继续把 Value 换成更贴近语义的后缀,例如:
export type DialogModeProp = `${DialogMode}`
export type ActionSheetCloseReasonValue = `${ActionSheetCloseReason}`后缀只是帮助读者识别公开字符串值,真正要避免的是同一个名字在值、类型、公开契约之间反复变身。
适合用在哪些地方
这套模式适合三类场景:
- 组件公开 props。外部写字符串最自然,内部判断希望用 enum 常量。
- 命令式 UI 服务。例如
dialog.open({ mode: 'confirm' }),调用方传配置,服务内部按模式分支。 - 跨组件共享的动作 key。菜单 action、弹窗 action、关闭原因这类值,既要在 UI 层读得懂,也要在事件回调里保持窄类型。
它不适合这些场景:
- 纯内部状态。如果值不会穿过公开边界,直接用
const enum类型就够了,不必再派生Value。 - 需要运行时枚举对象。只要有
Object.keys()、Object.values()、动态遍历、构建菜单列表,就不要用const enum。 - 后端或协议生成类型。生成代码优先尊重生成器和协议契约,不要为了统一风格强行改写。
- 大型字符串集合。TypeScript 官方文档也提醒,大字符串联合更适合 ahead-of-time generation。
'alert' | 'confirm' | 'prompt'这种小集合很合适,几百个值就不该手写这种模式。
Lint 护栏要抓模式,不要抓口味
这种写法很适合沉淀成团队规则。lint 的职责是抓模式,而不是粗暴地要求“所有字符串联合都必须改 enum”。
更合理的护栏是抓可疑模式:
export const DialogMode = {
Alert: 'alert',
Confirm: 'confirm',
Prompt: 'prompt',
} as const
export type DialogMode = (typeof DialogMode)[keyof typeof DialogMode]如果这个对象只是静态字符串集合,没有运行时遍历、没有对象映射、没有和后端数据结构同形,那么 lint 可以提示:
export const enum DialogMode {
Alert = 'alert',
Confirm = 'confirm',
Prompt = 'prompt',
}
export type DialogModeValue = `${DialogMode}`这类规则的重点是“减少模板型类型噪音,让枚举语义更明显”,而不是为了追求某种统一写法把所有对象常量都打掉。
真正需要保留对象的情况,要允许显式例外:
export const DialogModeLabel = {
[DialogMode.Alert]: 'Alert',
[DialogMode.Confirm]: 'Confirm',
[DialogMode.Prompt]: 'Prompt',
} as const这个对象承担的是“值到展示文案的映射”,应该继续保留对象形态。
测试要覆盖公开写法
这种类型设计最终服务的是 API 体验,所以测试也应该覆盖公开写法,而不只覆盖内部常量。
比如组件测试里可以故意传普通字符串:
mount(CommonDialog, {
props: {
mode: 'prompt',
},
})内部单测或 composable 测试再覆盖 enum 常量:
expect(resolveDialogMode(DialogMode.Prompt)).toEqual({
inputVisible: true,
})这两个测试一起存在,才说明边界没有退化:外部仍然能用字符串,内部仍然能用 enum 读代码。
总结
const enum + `${Enum}` 不是一个必须到处使用的高级技巧,它更像一个很清晰的分工:
const enum给内部实现一个有名字、可搜索、可重构的常量来源。`${Enum}`把 enum 的字符串取值投影成普通字符串字面量联合。- 公开 props、配置和命令式 API 使用
XxxValue,保留自然的字符串调用方式。 - 内部分支、映射和判断使用
Xxx.Member,避免散落裸字符串。 - lint 只抓“纯字符串集合却绕成
as const + typeof + keyof”这类可疑模式,不替团队替所有类型设计做决定。
这类小机制的价值在于降低阅读成本。写代码时多分清一层“谁是公开 API、谁是内部实现”,后面读代码的人就少停顿一次;这样的停顿少了,组件库和业务代码都会更轻。
