MindGym 项目的 i18n 方案不使用 JSON 文件或 t('key.string') 字符串 API,而是把翻译写在 TypeScript 文件里,用 as const 保留字面量类型,通过模板字面量类型 + 条件类型在编译期实现完全的类型安全。本页记录这个方案的设计动机、依赖的 TS 类型武器、以及与主流方案的 trade-off。
概述:为什么翻译应该是代码而不是数据
传统 i18n 方案(react-intl、i18next)把翻译放在 JSON 文件里,通过 t('some.key') 字符串 API 访问。这带来三个结构性问题:
- 翻译分散:组件在
features/、翻译在locales/、类型定义在types/,改一个翻译跳三个地方 - IDE 不可见:字符串 key 无法 Cmd+Click 跳转,拼错 key 无编译提示
- 缺翻译静默失败:中文 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}}:
- 无插值 → 直接返回字符串
- 有插值 → 包装成
(params) => string闭包
useStrings Hook 从 Context 获取当前 locale,用 useMemo 缓存解析结果。
使用效果
const t = useStrings(trainStrings)
t.trainStart // string,直接用
t.trainTitle({ module: '舒尔特' }) // ✅
t.trainTitle() // ❌ 编译报错:缺少参数
t.trainTitle({ modul: '舒尔特' }) // ❌ 编译报错:参数名拼错
与主流方案的 trade-off
| react-i18next | typesafe-i18n | MindGym 方案 | |
|---|---|---|---|
| 类型安全 | 需手动维护 resources.d.ts | 代码生成 | 纯类型推导 |
| 缺翻译检测 | 运行时 | 编译期 | 编译期 |
| 外部依赖 | npm 包 | npm 包 + 生成器 | 零依赖(~100 行) |
| 复数/ICU | 完整支持 | 支持 | 不支持 |
| 翻译平台集成 | 成熟 | 有限 | 需自建导出脚本 |
| 适用场景 | 大团队/多语言 | 中型项目 | 小团队/少量语言 |
MindGym 方案的不足:不支持复数规则、不支持动态加载、翻译工作流需要自建。适合双语小项目,不适合 30 种语言的大项目。
参见
- Expo vs 裸 RN — MindGym 的 Expo 工作流选择
- NativeWind 与 Tailwind 设计哲学 — 另一个”代码优先”的设计决策
参考
- TypeScript Handbook: Template Literal Types
- TypeScript 5.0: const Type Parameters
- Total TypeScript — Matt Pocock 的 TS 高级类型实战教程
- react-i18next TypeScript 配置
- typesafe-i18n — 思路接近的第三方库,通过代码生成实现