Vai al contenuto
26 maggio 20264 min readGuides

i18next type-safe nei monorepo: arriva ResourceNamespaceMap

Se avete mai provato ad aggiungere traduzioni type-safe a un monorepo con i18next, probabilmente avete sbattuto contro questo muro: TypeScript si rifiutava di lasciare che due package dichiarassero ciascuno i propri namespace. O centralizzavate ogni namespace nel CustomTypeOptions.resources della leaf app (compresi quelli che l'app non usava mai direttamente), oppure ricorrevate a '@repo/ui': any come cerotto, rinunciando alla type safety sui namespace condivisi tra package.

i18next v26.3.0 risolve la questione. Ogni package può ora dichiarare i propri namespace nel proprio i18next.d.ts, e TypeScript li unisce automaticamente. Niente più collisioni, niente più any, niente più registry centralizzato.

Il runtime non è mai stato il problema

Per chiarezza: a runtime, questo ha sempre funzionato. Potevate tranquillamente chiamare useTranslation('@repo/ui') da qualunque package e funzionava. Il dolore era strettamente a livello di tipi. Il narrowing delle chiavi di t() da parte di TypeScript dipendeva da un'unica augmentation globale di CustomTypeOptions.resources, quindi se volevate chiavi tipizzate e avevate più package, eravate bloccati.

Se non usate traduzioni type-safe, non c'è bisogno di leggere oltre. Ma se le usate, la v26.3.0 è l'aggiornamento che stavate aspettando.

Perché un singolo campo resources non funzionava

Lo schema legacy era questo:

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

CustomTypeOptions è un'interface, e TypeScript unisce volentieri i membri attraverso più blocchi declare module, purché i tipi delle proprietà combacino. Ma resources è una singola proprietà. Se due package dichiaravano ciascuno resources con shape diverse, TypeScript si arrendeva con TS2717:

Subsequent property declarations must have the same type.

Quello era il muro. Non potevate spedire le dichiarazioni di tipo insieme a ciascun package. La leaf app doveva conoscere i namespace di ogni dipendenza e fonderli in un unico blocco.

La soluzione: ResourceNamespaceMap

ResourceNamespaceMap è un'interface separata, progettata specificatamente per i contributi di namespace. Dato che ogni package aggiunge chiavi distinte (i propri nomi di namespace), l'interface merging di TypeScript funziona esattamente come previsto. Nessuna collisione di proprietà.

In packages/ui/i18next.d.ts, la libreria UI condivisa dichiara solo il proprio 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, la leaf app dichiara solo i propri namespace:

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'
  }
}

Quando consumate il package come sorgente (che è il comportamento di default nei monorepo come Turborepo), TypeScript fonde il registry automaticamente. useTranslation('@repo/ui') supera il type check all'interno del package UI, useTranslation('common') funziona all'interno dell'app, e nessuna delle due parti deve importare o conoscere i namespace dell'altra.

Bonus: helper tipizzati per package

Una volta che ogni package possiede i propri namespace, potete spedire degli hook helper tipizzati al loro fianco. Il trucco è estrarre dal registry fuso solo i namespace che appartengono a quel package. Extract combinato con un template literal type lo fa in una sola riga:

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

I componenti del package possono chiamare useUiTranslation() senza argomenti e ottenere una t tipizzata sul namespace UI. Se in futuro il package cresce e contribuisce @repo/ui/forms, @repo/ui/buttons, ecc. al registry, l'helper li raccoglie automaticamente, senza alcuna union mantenuta a mano che possa andare fuori sincrono.

Convive con lo schema legacy

Se il vostro progetto usa già CustomTypeOptions.resources, per voi non cambia nulla. Il campo legacy continua a funzionare, ed entrambe le superfici alimentano TypeOptions['resources']. Potete introdurre ResourceNamespaceMap in modo incrementale (per esempio, per un nuovo package condiviso) senza toccare il resto dell'augmentation.

Cose da sapere

  • I conflitti sulla stessa chiave vengono scartati silenziosamente. Se due dichiarazioni contribuiscono lo stesso namespace con la stessa chiave ma tipi literal diversi (poniamo 'A' in un package, 'B' in un altro), quella specifica chiave viene filtrata fuori dalla shape fusa. Chiamare t() con essa restituisce un errore di chiave sconosciuta. Le altre chiavi nel namespace non sono interessate. È una scelta intenzionale: trasformare il conflitto in un errore bloccante corromperebbe l'intera tipizzazione del namespace.
  • Le opzioni scalari restano su CustomTypeOptions. ResourceNamespaceMap è dedicata solo ai tipi dei namespace. Opzioni come defaultNS, returnNull, enableSelector, parseInterpolation continuano a vivere su CustomTypeOptions e seguono le regole esistenti di dichiarazione singola. Trattandosi di scalari, in pratica raramente generano conflitti tra package.
  • L'API Selector funziona allo stesso modo. Se usate enableSelector, percorre il registry fuso esattamente come percorreva il resources legacy. Non serve alcun setup aggiuntivo.

Un ringraziamento

Questa funzionalità è stata realizzata grazie a @sh3xu, che ha portato l'idea originale dalla issue #2409 fino in fondo, passando per implementazione, test e la rifinitura della logica di merge a livello di tipi per gestire ogni edge case, incluso un caso particolarmente insidioso in cui i conflitti sulla stessa chiave facevano collassare silenziosamente l'intero namespace. La conversazione nella PR #2434 vale la lettura se volete tutta la storia a livello di tipi.

Provatelo

Aggiornate a i18next@^26.3.0 e siete pronti. Il setup completo è documentato nella guida TypeScript.

Se non avete mai lavorato prima con i18next type-safe, la nostra guida TypeScript originale è il punto di partenza, e il post sull'API Selector copre l'altro grande miglioramento TypeScript dell'ultimo anno.

Cercate un posto dove gestire tutti questi file di traduzione? Locize si sposa pulitamente con questo schema: un progetto Locize, un namespace per package del monorepo, type-safe da capo a fondo.