Типобезопасные хелперы в контексте i18next строятся
вокруг идеи строгого соответствия между структурой переводов и
доступными ключами в коде. Основная цель — исключить обращение к
несуществующим ключам и обеспечить проверку параметров интерполяции на
уровне TypeScript.
Базовая конфигурация начинается с описания структуры ресурсов:
export const resources = {
ru: {
common: {
greeting: "Привет, {{name}}",
unread: "Непрочитанные сообщения: {{count}}",
},
},
en: {
common: {
greeting: "Hello, {{name}}",
unread: "Unread messages: {{count}}",
},
},
} as const;
Ключевой момент заключается в использовании as const,
позволяющего зафиксировать литеральные типы строк. Без этого TypeScript
деградирует до широких типов string, что лишает систему
смысловой информации о ключах.
На основе ресурсов создаётся типовая модель, описывающая доступные языки, неймспейсы и ключи:
type Resources = typeof resources;
type AppLocales = keyof Resources; // "ru" | "en"
type AppNamespaces = keyof Resources["ru"]; // "common"
Дальнейшее расширение позволяет извлекать ключи конкретного неймспейса:
type CommonKeys = keyof Resources["ru"]["common"];
// "greeting" | "unread"
Такой подход уже позволяет строить ограниченные интерфейсы для
t, но не решает проблему интерполяции.
Структура строк в i18next поддерживает плейсхолдеры. Для
строгой типизации требуется явное описание параметров:
type CommonInterpolation = {
greeting: { name: string };
unread: { count: number };
};
Связка ключей и параметров позволяет построить универсальный тип:
type TranslationParams<K extends keyof CommonInterpolation> =
CommonInterpolation[K];
Функция перевода приобретает строгую сигнатуру:
function t<K extends keyof CommonInterpolation>(
key: K,
params: TranslationParams<K>
) {}
Это исключает возможность передачи лишних или отсутствующих параметров.
Базовый экземпляр i18next остаётся нетипизированным по
умолчанию, поэтому создаётся обёртка:
import i18next from "i18next";
function typedT<K extends keyof CommonInterpolation>(
key: K,
params: TranslationParams<K>
) {
return i18next.t(key as string, params);
}
Такой слой фиксирует контракт между ключами и параметрами, сохраняя совместимость с внутренним API.
При расширении системы используется извлечение ключей из ресурсов с учётом namespace:
type Join<N extends string, K extends string> = `${N}.${K}`;
type CommonPrefixedKeys = Join<"common", keyof Resources["ru"]["common"]>;
// "common.greeting" | "common.unread"
Это позволяет моделировать реальные ключи i18next, где
namespace является частью строки.
Хук useTranslation требует отдельной обёртки для
сохранения строгой типизации:
type UseTranslationReturn = {
t: typeof typedT;
};
function useTypedTranslation(): UseTranslationReturn {
const { t } = i18next;
return {
t: (key, params) => t(key as string, params),
};
}
Более строгий вариант предполагает привязку namespace:
type Namespace = "common";
type NamespacedKeys =
keyof Resources["ru"][Namespace];
function useNamespaceTranslation(ns: Namespace) {
return {
t: (key: NamespacedKeys, params?: any) =>
i18next.t(`${ns}.${key}`, params),
};
}
Для устранения ручного описания параметров используется условная типизация:
type ParamsMap = {
greeting: { name: string };
unread: { count: number };
};
type ParamsFor<K extends keyof ParamsMap> = ParamsMap[K];
Дальнейшее развитие — автоматизация через сопоставление ключей:
type StrictT = <K extends keyof ParamsMap>(
key: K,
params: ParamsFor<K>
) => string;
Такой тип становится основой всей системы безопасных переводов.
При использовании единого объекта ресурсов можно вывести типы автоматически:
const typedResources = {
greeting: "Привет, {{name}}",
unread: "Непрочитанные: {{count}}",
} as const;
type TypedKeys = keyof typeof typedResources;
Далее создаётся универсальный маппинг интерполяции:
type ExtractParams<T extends string> =
T extends `{{${infer P}}}` ? P : never;
Однако в реальных условиях применяется более сложный анализ шаблонов, поскольку строки могут содержать несколько параметров.
Обобщённая функция, объединяющая ключи и параметры:
type TranslationDictionary = {
greeting: { name: string };
unread: { count: number };
};
type TypedTranslate = {
<K extends keyof TranslationDictionary>(
key: K,
params: TranslationDictionary[K]
): string;
};
Реализация:
const t: TypedTranslate = (key, params) => {
return i18next.t(key, params);
};
Не все переводы требуют интерполяции. Для этого вводится условная логика:
type OptionalParams<K extends keyof TranslationDictionary> =
keyof TranslationDictionary[K] extends never
? []
: [params: TranslationDictionary[K]];
Функция приобретает перегрузки:
type TypedTranslateFn = {
<K extends keyof TranslationDictionary>(key: K, ...args: OptionalParams<K>): string;
};
Это позволяет вызывать переводчик без аргументов там, где они не требуются.
При работе с несколькими namespaces создаётся карта:
type Namespaces = {
common: {
greeting: { name: string };
unread: { count: number };
};
auth: {
login: { email: string; password: string };
};
};
Типизированный переводчик:
type NamespaceKeys<N extends keyof Namespaces> =
keyof Namespaces[N];
type NsTranslate<N extends keyof Namespaces> = {
<K extends NamespaceKeys<N>>(
key: `${N}.${K}`,
params: Namespaces[N][K]
): string;
};
Для масштабируемых приложений вводится слой композиции:
type AppI18n = {
t: TypedTranslateFn;
useNamespace: <N extends keyof Namespaces>(
ns: N
) => NsTranslate<N>;
};
Такой слой обеспечивает единый контракт для всех уровней доступа к переводам.
Типобезопасные хелперы устраняют следующие классы ошибок:
Каждая из этих проблем переносится в фазу компиляции, исключая выполнение некорректного кода в рантайме.
При подключении к реальному экземпляру важно сохранить синхронизацию типов и runtime-конфигурации:
i18next.init({
resources,
lng: "ru",
ns: ["common"],
defaultNS: "common",
});
Типизированный слой остаётся надстройкой, не заменяющей сам
i18next, а ограничивающей его использование.
Для масштабных проектов используется module augmentation:
declare module "i18next" {
interface CustomTypeOptions {
resources: typeof resources;
}
}
Это позволяет встроить структуру переводов в сам механизм
i18next, усиливая проверку типов на уровне всей
библиотеки.
При росте проекта вводится разделение:
Такое разбиение позволяет избежать монолитного типа и поддерживать расширяемость без деградации читаемости системы типов.