Zum Inhalt springen
26. Mai 20264 min readGuides

Type-Safe i18next in Monorepos: Vorstellung von ResourceNamespaceMap

Wenn Sie jemals versucht haben, typsichere Übersetzungen in einem Monorepo mit i18next einzuführen, sind Sie wahrscheinlich gegen diese Wand gelaufen: TypeScript hat sich geweigert, zwei Packages ihre jeweils eigenen Namespaces deklarieren zu lassen. Entweder mussten Sie jeden Namespace zentral in CustomTypeOptions.resources der Leaf-App zusammenführen (einschliesslich solcher, die die App nie direkt nutzte), oder Sie haben zu '@repo/ui': any als Notlösung gegriffen und damit die Type-Safety für package-übergreifende Namespaces aufgegeben.

i18next v26.3.0 behebt das. Jedes Package kann jetzt seine eigenen Namespaces in seiner eigenen i18next.d.ts deklarieren, und TypeScript merged sie automatisch. Keine Kollision mehr, kein any mehr, keine zentrale Registry mehr.

Runtime war nie das Problem

Zur Klarstellung: Zur Runtime hat das schon immer funktioniert. Sie konnten problemlos useTranslation('@repo/ui') aus jedem Package aufrufen, und es löste sich auf. Der Schmerz lag strikt auf der Type-Ebene. TypeScripts Verengung der t()-Keys hing von einer einzigen globalen CustomTypeOptions.resources-Augmentation ab. Wenn Sie also getypte Keys und mehrere Packages wollten, sassen Sie fest.

Wenn Sie keine typsicheren Übersetzungen nutzen, müssen Sie nicht weiterlesen. Aber falls doch, ist v26.3.0 das Upgrade, auf das Sie gewartet haben.

Warum ein einzelnes resources-Feld scheiterte

Das Legacy-Pattern sah so aus:

// some-package/i18next.d.ts
declare module 'i18next' {
  interface CustomTypeOptions {
    resources: { 'my-ns': typeof myNs }
  }
}

CustomTypeOptions ist ein Interface, und TypeScript merged Members über mehrere declare module-Blöcke hinweg problemlos, solange die Property-Typen übereinstimmen. Aber resources ist eine einzelne Property. Wenn zwei Packages resources mit unterschiedlichen Shapes deklarierten, brach TypeScript mit TS2717 ab:

Subsequent property declarations must have the same type.

Das ist die Wand. Sie konnten Typ-Deklarationen nicht zusammen mit jedem Package ausliefern. Die Leaf-App musste über jeden Namespace jeder Abhängigkeit Bescheid wissen und sie in einen Block zusammenführen.

Der Fix: ResourceNamespaceMap

ResourceNamespaceMap ist ein separates Interface, das speziell für Namespace-Beiträge konzipiert wurde. Da jedes Package eigenständige Keys (seine eigenen Namespace-Namen) hinzufügt, funktioniert das Interface-Merging von TypeScript genau wie vorgesehen. Keine Property-Kollision.

In packages/ui/i18next.d.ts deklariert die gemeinsam genutzte UI-Bibliothek nur ihren eigenen 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 deklariert die Leaf-App nur ihre eigenen 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'
  }
}

Wenn Sie das Package als Source konsumieren (was in Monorepos wie Turborepo der Default ist), merged TypeScript die Registry automatisch. useTranslation('@repo/ui') typechecked innerhalb des UI-Packages, useTranslation('common') funktioniert innerhalb der App, und keine Seite muss die Namespaces der anderen importieren oder kennen.

Bonus: pro-Package typisierte Helper

Sobald jedes Package seine eigenen Namespaces besitzt, können Sie typisierte Helper-Hooks gleich mit ausliefern. Der Trick besteht darin, aus der gemergten Registry genau die Namespaces zu extrahieren, die zu diesem Package gehören. Extract zusammen mit einem Template-Literal-Type erledigt das in einer Zeile:

// 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))
}

Die Komponenten des Packages können useUiTranslation() ohne Argumente aufrufen und erhalten ein t, das gegen den UI-Namespace typisiert ist. Wenn das Package wächst und später @repo/ui/forms, @repo/ui/buttons usw. zur Registry beiträgt, übernimmt der Helper sie automatisch, ohne eine handgepflegte Union, die abdriften könnte.

Koexistiert mit dem Legacy-Pattern

Wenn Ihr Projekt bereits CustomTypeOptions.resources nutzt, ändert sich für Sie nichts. Das Legacy-Feld funktioniert weiterhin, und beide Flächen speisen TypeOptions['resources']. Sie können ResourceNamespaceMap schrittweise einführen (zum Beispiel für ein neues, gemeinsam genutztes Package), ohne den Rest der Augmentation anzufassen.

Wissenswertes

  • Same-Key-Konflikte werden stillschweigend verworfen. Wenn zwei Deklarationen denselben Namespace mit demselben Key, aber unterschiedlichen Literal-Types beitragen (etwa 'A' in einem Package, 'B' in einem anderen), wird genau dieser Key aus dem gemergten Shape herausgefiltert. Ein t()-Aufruf damit schlägt als Unknown fehl. Andere Keys im Namespace bleiben unberührt. Das ist Absicht: Den Konflikt als harten Fehler hochzubringen würde sonst die Typisierung des gesamten Namespace zerstören.
  • Skalare Optionen bleiben auf CustomTypeOptions. ResourceNamespaceMap ist ausschliesslich für Namespace-Typen. Optionen wie defaultNS, returnNull, enableSelector, parseInterpolation leben weiterhin auf CustomTypeOptions und folgen den bestehenden Single-Declaration-Regeln. Da sie Skalare sind, verursachen sie in der Praxis selten package-übergreifende Konflikte.
  • Selector-API funktioniert genauso. Wenn Sie enableSelector nutzen, läuft sie über die gemergte Registry, genauso wie sie über die Legacy-resources lief. Kein zusätzliches Setup nötig.

Ein Dankeschön

Dieses Feature ist dank @sh3xu entstanden, der die ursprüngliche Idee aus Issue #2409 den ganzen Weg über Implementierung, Tests und Verfeinerung der Type-Level-Merge-Logik gebracht hat, um jeden Edge Case zu behandeln, einschliesslich eines besonders kniffligen Falls, bei dem Same-Key-Konflikte den gesamten Namespace stillschweigend kollabieren liessen. Die Diskussion in PR #2434 ist lesenswert, wenn Sie die vollständige Type-Level-Story möchten.

Probieren Sie es aus

Aktualisieren Sie auf i18next@^26.3.0 und Sie sind startbereit. Das vollständige Setup ist im TypeScript-Guide dokumentiert.

Wenn Sie noch nie mit typsicherem i18next gearbeitet haben, ist unser ursprünglicher TypeScript-Guide der richtige Einstieg, und der Selector-API-Beitrag behandelt die andere grosse TypeScript-Verbesserung des letzten Jahres.

Suchen Sie einen Ort, um all diese Übersetzungsdateien zu verwalten? Locize passt sauber auf dieses Pattern: ein Locize-Projekt, ein Namespace pro Monorepo-Package, Ende-zu-Ende typsicher.