用 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 的直觉。两边都舒服,类型系统还没有放松。

const enum 和字符串值类型的职责分工

问题从公开 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。这样它不会误伤内部组件状态,也不会把团队口味变成全仓禁令。

prop 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 的中转常量。真正承担映射、展示文案、运行时遍历的对象仍然应该保留。

template enum member alias 诊断示例正在加载代码工作区...

如果项目还没准备好接 ESLint 规则,也可以先用一个窄的架构测试守少数目录:只扫描约定目录里的 const xxx = XxxMode.Memberconst 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 示例:

Enum 公开 API 护栏源码正在加载代码工作区...

测试要覆盖公开写法

这种类型设计最终服务的是 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、谁是内部实现」,后面读代码的人就少停顿一次;这样的停顿少了,组件库和业务代码都会更轻。