前置知识: TypeScript

类型安全的国际化

3 minIntermediate2026/6/14

构建类型安全的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