用 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 护栏要抓边界,不要抓口味
这种写法很适合沉淀成团队规则,但规则不能粗暴地要求「所有字符串联合都必须改 enum」,也不能禁止所有 enum prop。真正要守的是两条边界。
第一条边界是:被设计成公开字符串 API 的 enum,不要直接暴露到 Vue prop 类型里。
<script setup lang="ts">
const enum DialogMode {
Prompt = 'prompt',
}
defineProps<{
mode: DialogMode
}>()
</script>如果 DialogMode 已经决定要让调用方写 mode="prompt",那 prop 类型就应该是:
<script setup lang="ts">
const enum DialogMode {
Prompt = 'prompt',
}
type DialogModeValue = `${DialogMode}`
defineProps<{
mode: DialogModeValue
}>()
</script>源码包里的 no-enum-prop-type 只提醒显式配置进 disallowedEnumNames 的 enum。这样它不会误伤内部组件状态,也不会把团队口味变成全仓禁令。
第二条边界是:template 可以直接使用 enum member,不需要再包一层一比一 alias。
<script setup lang="ts">
const enum DialogMode {
Confirm = 'confirm',
}
const confirmMode = DialogMode.Confirm
</script>
<template>
<CommonDialog :mode="confirmMode" />
</template>这类 confirmMode 很容易让读代码的人误以为它是新的业务概念。更直接的写法是:
<script setup lang="ts">
const enum DialogMode {
Confirm = 'confirm',
}
</script>
<template>
<CommonDialog :mode="DialogMode.Confirm" />
</template>no-template-enum-member-alias 只抓这种 enum member 的中转常量。真正承担映射、展示文案、运行时遍历的对象仍然应该保留。
如果项目还没准备好接 ESLint 规则,也可以先用一个窄的架构测试守少数目录:只扫描约定目录里的 const xxx = XxxMode.Member、const xxx = XxxVariant.Member 这类写法。这个测试不如 ESLint 精准,但作为迁移过渡很轻。
如果要让 Agent 接入这套源码包,可以直接给它这段 prompt:
工具包根路径:https://shengsheng.fun/files/const-enum-string-value-type/kits/enum-public-api-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md;迁移 copy/eslint/rules/ 和 copy/tests/,只把需要公开字符串调用面的 enum 写进 disallowedEnumNames,不要全仓禁止 enum prop。完整源码包在下面,包含两条 ESLint 规则、过渡架构测试和 flat config 示例:
测试要覆盖公开写法
这种类型设计最终服务的是 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 只抓「公开字符串 API 误暴露 enum prop」和「template 给 enum member 包中转常量」这类边界问题,不替团队给所有类型设计做决定。
这类小机制的价值在于降低阅读成本。写代码时多分清一层「谁是公开 API、谁是内部实现」,后面读代码的人就少停顿一次;这样的停顿少了,组件库和业务代码都会更轻。
