Type-Safe i18next in Monorepos: Introducing ResourceNamespaceMap
If you've ever tried to add type-safe translations to a monorepo with i18next, you've probably hit this wall: TypeScript refused to let two packages each declare their own namespaces. You either had to centralize every namespace into the leaf app's CustomTypeOptions.resources (including ones the app never used directly), or you reached for '@repo/ui': any as a bandaid and gave up type safety on cross-package namespaces.
i18next v26.3.0 fixes this. Each package can now declare its own namespaces in its own i18next.d.ts, and TypeScript merges them automatically. No more collision, no more any, no more central registry.
Runtime was never the problem
To be clear: at runtime, this always worked. You could happily call useTranslation('@repo/ui') from any package and it would resolve. The pain was strictly at the type level. TypeScript's narrowing of t() keys depended on a single, global CustomTypeOptions.resources augmentation, so if you wanted typed keys and you had multiple packages, you were stuck.
If you don't use type-safe translations, you don't need to read further. But if you do, v26.3.0 is the upgrade you've been waiting for.
Why a single resources field broke down
The legacy pattern looked like this:
// some-package/i18next.d.ts
declare module 'i18next' {
interface CustomTypeOptions {
resources: { 'my-ns': typeof myNs }
}
}CustomTypeOptions is an interface, and TypeScript happily merges members across multiple declare module blocks, as long as the property types match. But resources is a single property. If two packages each declared resources with different shapes, TypeScript bailed with TS2717:
Subsequent property declarations must have the same type.
That's the wall. You couldn't ship type declarations alongside each package. The leaf app had to know about every dependency's namespaces and merge them into one block.
The fix: ResourceNamespaceMap
ResourceNamespaceMap is a separate interface, designed specifically for namespace contributions. Because each package adds distinct keys (their own namespace names), TypeScript's interface merging works exactly as intended. No property collision.
In packages/ui/i18next.d.ts, the shared UI library declares only its own namespace:
import 'i18next'
import type uiTranslations from './translations/en/ui.json'
declare module 'i18next' {
interface ResourceNamespaceMap {
'@repo/ui': typeof uiTranslations
}
}In apps/frontend/i18next.d.ts, the leaf app declares only its own 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'
}
}When you consume the package as source (which is the default in monorepos like Turborepo), TypeScript merges the registry automatically. useTranslation('@repo/ui') typechecks inside the UI package, useTranslation('common') works inside the app, and neither side has to import or know about the other's namespaces.
Bonus: per-package typed helpers
Once each package owns its own namespaces, you can ship typed helper hooks alongside them. The trick is to extract just the namespaces that belong to this package from the merged registry. Extract with a template literal type does it in one line:
// 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))
}The package's components can call useUiTranslation() with no arguments and get a t typed against the UI namespace. If the package grows and contributes @repo/ui/forms, @repo/ui/buttons, etc. to the registry later, the helper picks them up automatically, with no hand-maintained union to drift.
Coexists with the legacy pattern
If your project already uses CustomTypeOptions.resources, nothing changes for you. The legacy field continues to work, and both surfaces feed TypeOptions['resources']. You can introduce ResourceNamespaceMap incrementally (for example, for a new shared package) without touching the rest of the augmentation.
Things to know
- Same-key conflicts are silently dropped. If two declarations contribute the same namespace with the same key but different literal types (say
'A'in one package,'B'in another), that specific key is filtered out of the merged shape. Callingt()with it errors as unknown. Other keys in the namespace are unaffected. This is intentional: surfacing the conflict as a hard error would otherwise corrupt the entire namespace's typing. - Scalar options stay on
CustomTypeOptions.ResourceNamespaceMapis namespace-types-only. Options likedefaultNS,returnNull,enableSelector,parseInterpolationcontinue to live onCustomTypeOptionsand follow the existing single-declaration rules. Since they're scalars, they rarely cause cross-package conflicts in practice. - Selector API works the same way. If you use
enableSelector, it walks the merged registry just like it walked the legacyresources. No additional setup needed.
A thank you
This feature shipped thanks to @sh3xu, who took the original idea from issue #2409 all the way through implementation, testing, and refining the type-level merge logic to handle every edge case, including a particularly tricky one where same-key conflicts silently collapsed the entire namespace. The conversation in PR #2434 is worth reading if you want the full type-level story.
Try it out
Update to i18next@^26.3.0 and you're ready to go. The full setup is documented in the TypeScript guide.
If you haven't worked with type-safe i18next before, our original TypeScript guide is the place to start, and the selector API post covers the other big TypeScript improvement from the last year.
Looking for a place to manage all those translation files? Locize maps cleanly onto this pattern: one Locize project, one namespace per monorepo package, type-safe end to end.