用 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 护栏要抓模式,不要抓口味

这种写法很适合沉淀成团队规则。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、谁是内部实现”,后面读代码的人就少停顿一次;这样的停顿少了,组件库和业务代码都会更轻。