Skip to content
雲里
里雾

类型安全的 i18n 设计 — as const + 模板字面量类型

mindgym 开发 更新于 2026/4/12

MindGym 项目的 i18n 方案不使用 JSON 文件或 t('key.string') 字符串 API,而是把翻译写在 TypeScript 文件里,用 as const 保留字面量类型,通过模板字面量类型 + 条件类型在编译期实现完全的类型安全。本页记录这个方案的设计动机、依赖的 TS 类型武器、以及与主流方案的 trade-off。


概述:为什么翻译应该是代码而不是数据

传统 i18n 方案(react-intl、i18next)把翻译放在 JSON 文件里,通过 t('some.key') 字符串 API 访问。这带来三个结构性问题:

  1. 翻译分散:组件在 features/、翻译在 locales/、类型定义在 types/,改一个翻译跳三个地方
  2. IDE 不可见:字符串 key 无法 Cmd+Click 跳转,拼错 key 无编译提示
  3. 缺翻译静默失败:中文 JSON 有某个 key、英文 JSON 缺了,到运行时才发现

根因在于 JSON 是数据,TypeScript 编译器不分析其内容。如果翻译写在 TS 文件里,编译器就能接管一切检查。


工作方式

翻译 bundle 的定义

翻译写在 TS 文件里,和使用它的 feature 放在同一目录:

// i18n/train.ts
import { defineBundle } from '@/i18n/types'

export const trainStrings = defineBundle({
  zh: {
    trainTitle: '{{module}} 训练',
    trainStart: '开始训练',
  },
  en: {
    trainTitle: '{{module}} Training',
    trainStart: 'Start',
  },
})

defineBundle 是一个泛型工厂函数,利用 TS 5.0 的 const 类型参数自动保留字面量类型:

export function defineBundle<const T extends StringsBundle<T['zh']>>(bundle: T): T {
  return bundle
}

5 种 TS 高级类型武器

整个方案依赖 5 种 TS 类型系统能力的组合:

1. 模板字面量类型 (Template Literal Types, TS 4.1+)

在类型层面对字符串做模式匹配。ExtractParamNames 递归匹配 {{...}} 模式,从 '{{module}} 训练' 中提取出 'module':

type ExtractParamNames<S extends string> = S extends `${string}{{${infer P}}}${infer Rest}`
  ? P | ExtractParamNames<Rest>
  : never

2. 条件类型 + infer

类型层面的 if-else。TranslationValue 根据翻译字符串是否含 {{}} 决定运行时类型:

type TranslationValue<S extends string> = S extends `${string}{{${string}}}${string}`
  ? (params: ExtractParams<S>) => string  // 有插值 → 函数
  : string                                 // 无插值 → 纯字符串

3. 映射类型 (Mapped Types)

对翻译表的每个 key 做类型变换,生成运行时类型:

{ [K in keyof T['zh']]: TranslationValue<T['zh'][K] & string> }

4. const 类型参数 (TS 5.0+)

<const T> 让泛型参数自动推断为最窄字面量类型,调用方不需要手写 as const。没有它,'{{module}} 训练' 会被拓宽为 string,模式匹配断掉。

5. F-bounded Polymorphism

T extends StringsBundle<T['zh']> — 用 T 自身的 zh 作为基准约束 T 的所有语言,确保 key 集合完全一致。en 缺 key → 编译错误。

运行时解析

resolveBundle 纯函数遍历翻译表,用正则判断是否含 {{key}}:

useStrings Hook 从 Context 获取当前 locale,用 useMemo 缓存解析结果。

使用效果

const t = useStrings(trainStrings)
t.trainStart                        // string,直接用
t.trainTitle({ module: '舒尔特' })   // ✅
t.trainTitle()                       // ❌ 编译报错:缺少参数
t.trainTitle({ modul: '舒尔特' })   // ❌ 编译报错:参数名拼错

与主流方案的 trade-off

react-i18nexttypesafe-i18nMindGym 方案
类型安全需手动维护 resources.d.ts代码生成纯类型推导
缺翻译检测运行时编译期编译期
外部依赖npm 包npm 包 + 生成器零依赖(~100 行)
复数/ICU完整支持支持不支持
翻译平台集成成熟有限需自建导出脚本
适用场景大团队/多语言中型项目小团队/少量语言

MindGym 方案的不足:不支持复数规则、不支持动态加载、翻译工作流需要自建。适合双语小项目,不适合 30 种语言的大项目。


参见


参考