类型安全的国际化
构建类型安全的i18n系统
概述
国际化(i18n)是前端应用支持多语言的核心能力。传统的 i18n 方案使用字符串键访问翻译文本,容易出现键名拼写错误和参数遗漏。通过 TypeScript 的字面量类型、条件类型和泛型约束,可以构建类型安全的 i18n 系统,确保翻译键的存在性、参数类型的正确性,并在编译时发现所有国际化相关的错误。
基础概念
翻译键类型:将所有翻译键定义为联合类型或接口,确保只能使用已定义的键。键名通常使用点分格式(如 app.title)组织层级。
参数化翻译:部分翻译文本包含动态参数(如 你好,{name})。通过类型定义每个键的参数结构,确保调用时传入正确的参数。
语言类型:将支持的语言定义为联合类型,确保只能使用已配置的语言代码。
翻译函数类型:根据键是否需要参数,翻译函数的签名自动变化。无参数键返回字符串,有参数键要求传入参数对象。
快速上手
基础类型定义
// 支持的语言
type Locale = 'zh-CN' | 'en-US';
// 翻译键及其参数类型
type TranslationKeys = {
'app.title': string; // 无参数
'user.greeting': { name: string }; // 需要 name 参数
'user.profile': { name: string; age: number }; // 需要多个参数
'items.count': { count: number }; // 需要数字参数
'errors.required': { field: string }; // 错误消息
};
// 翻译函数类型
type TranslatableKeys = {
[K in keyof TranslationKeys]: TranslationKeys[K] extends string
? () => string
: (params: TranslationKeys[K]) => string;
};
翻译实现
// 翻译数据
const translations: Record<Locale, Record<keyof TranslationKeys, string | Function>> = {
'zh-CN': {
'app.title': '我的应用',
'user.greeting': ({ name }: { name: string }) => `你好,${name}`,
'user.profile': ({ name, age }: { name: string; age: number }) => `${name},${age}岁`,
'items.count': ({ count }: { count: number }) => `${count} 个项目`,
'errors.required': ({ field }: { field: string }) => `${field} 不能为空`,
},
'en-US': {
'app.title': 'My App',
'user.greeting': ({ name }: { name: string }) => `Hello, ${name}`,
'user.profile': ({ name, age }: { name: string; age: number }) => `${name}, ${age} years old`,
'items.count': ({ count }: { count: number }) => `${count} items`,
'errors.required': ({ field }: { field: string }) => `${field} is required`,
},
};
// 当前语言
let currentLocale: Locale = 'zh-CN';
// 类型安全的翻译函数
function t<K extends keyof TranslationKeys>(
key: K,
...args: TranslationKeys[K] extends string ? [] : [TranslationKeys[K]]
): string {
const value = translations[currentLocale][key];
return typeof value === 'function' ? value(args[0]) : value;
}
// 使用
t('app.title'); // "我的应用"
t('user.greeting', { name: '张三' }); // "你好,张三"
// t('user.greeting'); // 错误:缺少 name 参数
// t('unknown.key'); // 错误:键不存在
详细用法
嵌套键结构
// 嵌套翻译键
interface NestedTranslations {
app: {
title: string;
subtitle: string;
};
user: {
greeting: { name: string };
profile: { name: string; age: number };
logout: string;
};
errors: {
required: { field: string };
invalid: { field: string; type: string };
network: string;
};
}
// 将嵌套键展开为点分路径
type FlattenKeys<T, Prefix extends string = ''> = T extends object
? T extends Function
? never
: {
[K in keyof T]: FlattenKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T]
: Prefix;
type FlatKey = FlattenKeys<NestedTranslations>;
// 'app.title' | 'app.subtitle' | 'user.greeting' | 'user.profile' | 'user.logout' | ...
复数形式处理
// 复数形式翻译
interface PluralTranslations {
'items.zero': string;
'items.one': string;
'items.other': { count: number };
}
const pluralTranslations: Record<Locale, PluralTranslations> = {
'zh-CN': {
'items.zero': '没有项目',
'items.one': '1 个项目',
'items.other': ({ count }) => `${count} 个项目`,
},
'en-US': {
'items.zero': 'No items',
'items.one': '1 item',
'items.other': ({ count }) => `${count} items`,
},
};
function pluralize(key: string, count: number): string {
if (count === 0)
return pluralTranslations[currentLocale][`${key}.zero` as keyof PluralTranslations] as string;
if (count === 1)
return pluralTranslations[currentLocale][`${key}.one` as keyof PluralTranslations] as string;
return (pluralTranslations[currentLocale][`${key}.other` as keyof PluralTranslations] as any)({
count,
});
}
日期和数字格式化
// 类型安全的格式化器
interface Formatters {
date: (value: Date, format: 'short' | 'long' | 'relative') => string;
number: (value: number, format: 'decimal' | 'currency' | 'percent') => string;
}
const formatters: Record<Locale, Formatters> = {
'zh-CN': {
date: (value, format) => {
switch (format) {
case 'short':
return value.toLocaleDateString('zh-CN');
case 'long':
return value.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
case 'relative':
return getRelativeTime(value, 'zh-CN');
}
},
number: (value, format) => {
switch (format) {
case 'decimal':
return value.toLocaleString('zh-CN');
case 'currency':
return `¥${value.toFixed(2)}`;
case 'percent':
return `${(value * 100).toFixed(1)}%`;
}
},
},
'en-US': {
date: (value, format) => {
switch (format) {
case 'short':
return value.toLocaleDateString('en-US');
case 'long':
return value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
case 'relative':
return getRelativeTime(value, 'en-US');
}
},
number: (value, format) => {
switch (format) {
case 'decimal':
return value.toLocaleString('en-US');
case 'currency':
return `$${value.toFixed(2)}`;
case 'percent':
return `${(value * 100).toFixed(1)}%`;
}
},
},
};
function getRelativeTime(date: Date, locale: string): string {
const diff = Date.now() - date.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return locale === 'zh-CN' ? '刚刚' : 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return locale === 'zh-CN' ? `${minutes}分钟前` : `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return locale === 'zh-CN' ? `${hours}小时前` : `${hours}h ago`;
}
常见场景
React 组件国际化
// 类型安全的 i18n Hook
function useTranslation() {
const [locale, setLocale] = useState<Locale>('zh-CN');
const t: typeof translate = useCallback((key, ...args) => {
currentLocale = locale;
return translate(key, ...args);
}, [locale]);
return { t, locale, setLocale };
}
// 在组件中使用
function UserProfile({ user }: { user: User }) {
const { t } = useTranslation();
return (
<div>
<h1>{t('user.greeting', { name: user.name })}</h1>
<p>{t('user.profile', { name: user.name, age: user.age })}</p>
</div>
);
}
翻译完整性检查
// 检查所有语言的翻译键是否完整
type MissingKeys<T extends Record<Locale, Record<string, any>>> = {
[L in Locale]: Exclude<keyof T['zh-CN'], keyof T[L]>;
};
// 使用 satisfies 确保翻译完整性
const allTranslations = {
'zh-CN': {
'app.title': '我的应用',
'user.greeting': ({ name }: { name: string }) => `你好,${name}`,
},
'en-US': {
'app.title': 'My App',
'user.greeting': ({ name }: { name: string }) => `Hello, ${name}`,
},
} satisfies Record<Locale, Record<keyof TranslationKeys, string | Function>>;
注意事项
- 翻译键的组织:使用点分格式组织翻译键,保持层级清晰。避免过深的嵌套(超过3层),否则类型定义会变得复杂。
- 参数类型一致性:同一翻译键在所有语言中的参数类型必须一致。使用共享的参数接口确保一致性。
- 运行时 vs 编译时:TypeScript 的类型安全仅在编译时有效。运行时仍需处理键不存在的情况,提供合理的回退机制。
- 翻译文件大小:大型应用的翻译文件可能很大。考虑按模块拆分翻译文件,按需加载。
进阶用法
ICU 消息格式
// 支持 ICU 消息格式的类型安全翻译
type ICUMessage = string; // 简化表示
interface ICUParser {
parse(message: ICUMessage, params: Record<string, unknown>): string;
}
// 类型安全的 ICU 翻译
function tICU<K extends keyof TranslationKeys>(
key: K,
params: TranslationKeys[K] extends string ? Record<string, never> : TranslationKeys[K]
): string {
const message = translations[currentLocale][key];
if (typeof message === 'string') return message;
return message(params as any);
}
自动翻译键提取
// 从代码中提取所有使用的翻译键
type UsedKeys<T extends readonly string[]> = T[number];
// 确保所有使用的键都有对应的翻译
type ValidateKeys<Used extends string, Defined extends string> = Used extends Defined
? true
: { error: '未定义的翻译键'; key: Used };
// 使用
type AllDefinedKeys = keyof TranslationKeys;
type MyUsedKeys = 'app.title' | 'user.greeting';
type Validation = ValidateKeys<MyUsedKeys, AllDefinedKeys>; // true