Строгая типизация ключей переводов

Проблема динамических ключей в i18next

В базовой конфигурации i18next система перевода работает с ключами строкового типа. Функция t принимает произвольную строку:

t('common.save')
t('errors.notFound')

Такой подход не ограничивает разработчика и допускает ряд проблем:

  • опечатки в ключах не обнаруживаются на этапе компиляции
  • удалённые или переименованные переводы остаются в коде
  • отсутствует автодополнение в IDE
  • структура переводов становится «скрытой» и неявной
  • рефакторинг ключей превращается в рискованную операцию

Строгая типизация решает эти проблемы, превращая ключи переводов в часть контрактной системы приложения.


Базовая идея типизации переводов

Основной принцип заключается в том, чтобы представить структуру переводов как тип данных TypeScript, а ключи — как объединение строковых литералов.

Пример ресурса переводов:

export const resources = {
  en: {
    common: {
      save: "Save",
      cancel: "Cancel"
    },
    errors: {
      notFound: "Not found"
    }
  }
} as const;

Ключевое значение здесь — as const, которое делает структуру неизменяемой и позволяет вывести точные литералы.

Далее можно получить тип ключей:

type Resources = typeof resources;

Однако этого недостаточно для полноценной типизации t, так как i18next работает с вложенными namespace и языками.


Формирование union-типа ключей

Для извлечения всех возможных путей используется рекурсивный тип:

type Join<K, P> = K extends string | number
  ? P extends string | number
    ? `${K}.${P}`
    : never
  : never;

type Paths<T> = {
  [K in keyof T]: T[K] extends object
    ? Join<K, Paths<T[K]>>
    : K
}[keyof T];

Применение:

type TranslationKeys = Paths<typeof resources.en>;

Результат:

"common.save" | "common.cancel" | "errors.notFound"

Теперь любые обращения к t можно ограничить этим типом.


Типизация функции t через перегрузки

i18next позволяет расширять типы через declaration merging.

Создаётся файл i18next.d.ts:

import 'i18next';
import type { resources } fr om './resources';

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: typeof resources['en'];
  }
}

После этого TypeScript начинает связывать ключи с реальной структурой ресурсов.


Ограничение ключей в runtime API

При правильной настройке можно добиться следующего поведения:

t('common.save');      // корректно
t('common.unknown');    // ошибка TypeScript
t('errros.notFound');   // ошибка TypeScript (опечатка)

Типизация предотвращает даже корректные, но отсутствующие ключи.


Типизация namespace

В i18next часто используется разделение на namespace:

{
  en: {
    common: {...},
    auth: {...}
  }
}

Для этого вводится дополнительная структура:

type AppResources = typeof resources['en'];

type Namespaces = keyof AppResources;

И расширенная форма ключей:

type NamespaceKeys<N extends keyof AppResources> =
  Paths<AppResources[N]>;

Использование:

type CommonKeys = NamespaceKeys<'common'>;

Интеграция с i18next и react-i18next

В связке с React используется react-i18next, который также поддерживает типизацию через module augmentation.

import 'react-i18next';
import type resources from './resources';

declare module 'react-i18next' {
  interface Resources {
    en: typeof resources['en'];
  }
}

После этого хук useTranslation начинает строго проверять ключи:

const { t } = useTranslation();

t('common.save');   // корректно
t('common.svae');   // ошибка

Типизация интерполяций

i18next поддерживает параметры:

t('welcome', { name: 'John' })

Без типизации параметры остаются any. Для строгого контроля можно описать интерфейсы:

interface InterpolationMap {
  'welcome': { name: string };
  'errors.lim it': { max: number };
}

И связать их с перегрузкой:

type TypedTFunction = <
  K extends keyof InterpolationMap
>(
  key: K,
  options: InterpolationMap[K]
) => string;

Теперь ошибка будет выбрасываться при неправильных параметрах:

t('welcome', { name: 'John' }); // ok
t('welcome', { age: 20 });       // ошибка

Жёсткое ограничение namespace по умолчанию

i18next позволяет задавать defaultNS, и это влияет на поведение ключей.

interface CustomTypeOptions {
  defaultNS: 'common';
}

Это позволяет писать:

t('save'); // интерпретируется как common.save

Но при строгой типизации требуется контроль:

  • либо все ключи автоматически привязываются к defaultNS
  • либо запрещается неявное использование namespace

Изоляция ключей через фабрики типов

Для крупных проектов используется генерация типов через утилиты:

type CreateKeys<T, Prefix extends string = ''> = {
  [K in keyof T]: T[K] extends object
    ? CreateKeys<T[K], `${Prefix}${K & string}.`>
    : `${Prefix}${K & string}`
}[keyof T];

Результат — единая система ключей без ручного поддержания типов.


Предотвращение «мертвых» ключей

Строгая типизация позволяет выявлять:

  • ключи, удалённые из JSON, но оставшиеся в коде
  • неиспользуемые переводы через TypeScript + линтер
  • расхождения между языковыми файлами

Комбинация типов и ESLint rule no-undefined-keys делает систему самопроверяемой.


Ограничения подхода

Несмотря на высокую точность, существуют ограничения:

  • сложные динамические ключи (t(\error.${code})) теряют строгую типизацию
  • генерация типов может увеличивать время компиляции
  • большие деревья переводов приводят к громоздким union-типам
  • требуется синхронизация между JSON и TS-типами

Для динамических случаев вводится частичное ослабление:

t(`error.${string}`)

что сохраняет баланс между гибкостью и безопасностью.


Стратегия масштабирования типизации

В крупных системах применяется комбинированный подход:

  • статические ключи полностью типизированы
  • динамические области ограничены шаблонными строками
  • namespace разделены на независимые типы
  • ресурсы генерируются из единого источника (JSON schema / TS source)

Такой подход позволяет сохранить строгую проверяемость без потери гибкости i18next.