i18next type-safe dans les monorepos : présentation de ResourceNamespaceMap
Si vous avez déjà essayé d'ajouter des traductions type-safe à un monorepo avec i18next, vous avez sans doute heurté ce mur : TypeScript refusait que deux packages déclarent chacun leurs propres namespaces. Vous deviez soit centraliser tous les namespaces dans le CustomTypeOptions.resources de l'app feuille (y compris ceux que l'app n'utilisait jamais directement), soit recourir à '@repo/ui': any comme rustine et renoncer à la sûreté de type sur les namespaces partagés entre packages.
i18next v26.3.0 corrige cela. Chaque package peut désormais déclarer ses propres namespaces dans son propre i18next.d.ts, et TypeScript les fusionne automatiquement. Fini les collisions, fini les any, fini le registre central.
Au runtime, ça n'a jamais été le problème
Pour être clair : au runtime, cela a toujours fonctionné. Vous pouviez appeler useTranslation('@repo/ui') depuis n'importe quel package et la résolution se faisait sans accroc. La douleur se situait strictement au niveau des types. La restriction des clés de t() par TypeScript reposait sur une unique augmentation globale de CustomTypeOptions.resources : si vous vouliez des clés typées et plusieurs packages, vous étiez coincés.
Si vous n'utilisez pas les traductions type-safe, vous n'avez pas besoin de lire la suite. Mais si vous les utilisez, la v26.3.0 est la mise à jour que vous attendiez.
Pourquoi un champ resources unique ne tenait plus
Le pattern legacy ressemblait à ceci :
// some-package/i18next.d.ts
declare module 'i18next' {
interface CustomTypeOptions {
resources: { 'my-ns': typeof myNs }
}
}CustomTypeOptions est une interface, et TypeScript fusionne sans souci les membres entre plusieurs blocs declare module, à condition que les types des propriétés correspondent. Mais resources est une propriété unique. Si deux packages déclaraient chacun resources avec des formes différentes, TypeScript bronchait avec TS2717 :
Subsequent property declarations must have the same type.
C'est le mur. Vous ne pouviez pas livrer les déclarations de type aux côtés de chaque package. L'app feuille devait connaître les namespaces de chaque dépendance et les fusionner dans un seul bloc.
La solution : ResourceNamespaceMap
ResourceNamespaceMap est une interface séparée, conçue spécifiquement pour les contributions de namespaces. Comme chaque package ajoute des clés distinctes (ses propres noms de namespace), la fusion d'interfaces de TypeScript fonctionne exactement comme prévu. Pas de collision de propriété.
Dans packages/ui/i18next.d.ts, la bibliothèque UI partagée déclare uniquement son propre namespace :
import 'i18next'
import type uiTranslations from './translations/en/ui.json'
declare module 'i18next' {
interface ResourceNamespaceMap {
'@repo/ui': typeof uiTranslations
}
}Dans apps/frontend/i18next.d.ts, l'app feuille déclare uniquement ses propres namespaces :
import 'i18next'
import type common from './translations/en/common.json'
import type pageHome from './translations/en/page-home.json'
declare module 'i18next' {
interface ResourceNamespaceMap {
common: typeof common
'page-home': typeof pageHome
}
// Scalar options stay on CustomTypeOptions
interface CustomTypeOptions {
defaultNS: 'common'
}
}Quand vous consommez le package depuis ses sources (ce qui est le défaut dans les monorepos comme Turborepo), TypeScript fusionne le registre automatiquement. useTranslation('@repo/ui') est correctement typé dans le package UI, useTranslation('common') fonctionne dans l'app, et aucun des deux côtés n'a besoin d'importer ni de connaître les namespaces de l'autre.
Bonus : helpers typés par package
Une fois que chaque package possède ses propres namespaces, vous pouvez livrer à côté des hooks helper typés. L'astuce consiste à extraire du registre fusionné uniquement les namespaces qui appartiennent à ce package. Extract combiné à un template literal type le fait en une ligne :
// packages/ui/src/useUiTranslation.ts
import { useTranslation } from 'react-i18next'
import type { FlatNamespace } from 'i18next'
type UiNamespace = Extract<FlatNamespace, `@repo/ui${string}`>
export function useUiTranslation<
Ns extends UiNamespace | readonly UiNamespace[] = '@repo/ui'
>(ns?: Ns) {
return useTranslation(ns ?? ('@repo/ui' as Ns))
}Les composants du package peuvent appeler useUiTranslation() sans argument et récupérer un t typé sur le namespace UI. Si le package grossit et contribue plus tard @repo/ui/forms, @repo/ui/buttons, etc. au registre, le helper les récupère automatiquement, sans union maintenue à la main qui risquerait de dériver.
Coexiste avec le pattern legacy
Si votre projet utilise déjà CustomTypeOptions.resources, rien ne change pour vous. Le champ legacy continue de fonctionner, et les deux surfaces alimentent TypeOptions['resources']. Vous pouvez introduire ResourceNamespaceMap de manière incrémentale (par exemple pour un nouveau package partagé) sans toucher au reste de l'augmentation.
Bon à savoir
- Les conflits sur une même clé sont silencieusement écartés. Si deux déclarations contribuent au même namespace la même clé avec des types literal différents (disons
'A'dans un package,'B'dans un autre), cette clé précise est filtrée hors de la forme fusionnée. L'appeler avect()renvoie une erreur «unknown». Les autres clés du namespace ne sont pas affectées. C'est volontaire : faire remonter le conflit en erreur dure corromprait sinon le typage de tout le namespace. - Les options scalaires restent sur
CustomTypeOptions.ResourceNamespaceMapne concerne que les types de namespaces. Les options commedefaultNS,returnNull,enableSelector,parseInterpolationcontinuent de vivre surCustomTypeOptionset suivent les règles existantes de déclaration unique. Comme ce sont des scalaires, elles provoquent rarement des conflits entre packages en pratique. - L'API Selector fonctionne de la même manière. Si vous utilisez
enableSelector, il parcourt le registre fusionné exactement comme il parcourait leresourceslegacy. Aucune configuration supplémentaire requise.
Un merci
Cette fonctionnalité a vu le jour grâce à @sh3xu, qui a porté l'idée originale de l'issue #2409 jusqu'au bout : implémentation, tests, raffinement de la logique de merge au niveau type pour couvrir chaque cas limite, y compris un cas particulièrement épineux où les conflits sur la même clé faisaient s'effondrer silencieusement tout le namespace. La discussion dans la PR #2434 vaut la lecture si vous voulez l'histoire complète au niveau type.
Essayez
Mettez à jour vers i18next@^26.3.0 et vous êtes prêt. La configuration complète est documentée dans le guide TypeScript.
Si vous n'avez jamais travaillé avec i18next type-safe auparavant, notre guide TypeScript original est le point de départ idéal, et l'article sur l'API selector couvre l'autre grande avancée TypeScript de la dernière année.
Vous cherchez un endroit pour gérer tous ces fichiers de traduction ? Locize s'aligne parfaitement sur ce pattern : un projet Locize, un namespace par package du monorepo, type-safe de bout en bout.