Namespaces

A namespace is one feature's set of translations. You define the default locale as a TypeScript const, then register that plus a loader per other locale so each language loads on demand.

@mmstack/translatenpm

Grouping strings by feature (a quote namespace, a userProfile namespace, a common one) is what makes lazy loading work: a route only pulls in the translations it needs, and the default locale for one feature is not tied to another's. The shape of the default-locale object is also the source of truth for type checking, so every other locale is validated against it, so you can never forget to add a key to one locale before deploying.

#Defining a namespace

Write the default-locale translations as a plain nested object and pass them to createNamespace with a name. Values are ICU messages, so a placeholder like { name } declares a parameter, and plural or select arms work as usual. The nested shape becomes the set of valid keys, and each message's placeholders become that key's required parameters.

quote.namespace.ts
import { createNamespace } from '@mmstack/translate';

const ns = createNamespace('quote', {
  pageTitle: 'Famous Quotes',
  greeting: 'Hello {name}!',
  detail: {
    authorLabel: 'Author',
  },
  errors: {
    minLength: 'Quote must be at least {min} characters long.',
  },
  stats: '{count, plural, one {# quote} other {# quotes}} available',
});

export default ns.translation;
export type QuoteLocale = (typeof ns)['translation'];
export const createQuoteTranslation = ns.createTranslation;

The three exports at the bottom each have a job. The default export is the compiled translation the loader picks up. QuoteLocale is the type you hand to the pipe and directive later (see Reading translations). And createQuoteTranslation is the factory every other locale is authored through, which is what enforces the shape.

#Recipe: add a new locale

Adding French to an app that already speaks English and Slovenian is two edits. First, a new file authored through the factory. Because createQuoteTranslation is typed to the default shape, a missing key, an extra key, or a dropped placeholder is a compile error, so you cannot ship a half-translated file by accident. The placeholders have to match, but the surrounding words do not, and ICU arms can differ per language (French, like Slovenian, has its own plural categories).

quote-fr.translation.ts
import { createQuoteTranslation } from './quote.namespace';

// The shape is checked against the default locale: a missing key, an extra
// key, or a dropped {name}/{min}/{count} placeholder is a compile error.
export default createQuoteTranslation('fr-FR', {
  pageTitle: 'Citations Célèbres',
  greeting: 'Bonjour {name} !',
  detail: {
    authorLabel: 'Auteur',
  },
  errors: {
    minLength: 'La citation doit comporter au moins {min} caractères.',
  },
  stats: '{count, plural, one {# citation} other {# citations}} disponibles',
});

Second, register the loader (the next section covers the call). That is the whole change; nothing in the components that read quote.* keys has to move.

#Registering for lazy loading

registerNamespace takes the default-locale loader and a map of the other locales, each a () => Promise<...> factory. It returns a [injectT, resolveTranslations] tuple. Tuple destructuring lets each call site pick its own names, which reads better once you have more than one namespace in play.

quote.t.ts
import { registerNamespace } from '@mmstack/translate';

export const [injectQuoteT, resolveQuoteTranslations] = registerNamespace(
  // default locale, also the fallback
  () => import('./quote.namespace'),
  {
    // other locales, each a () => Promise<...> factory
    'sl-SI': () => import('./quote-sl.translation'),
    'fr-FR': () => import('./quote-fr.translation'), // the new one
  },
);

A loader can return a compiled translation directly, or an ES module that exposes one as default or as a named translation export. The library unwraps all three, so a bare dynamic import() is the shortest form and the one to reach for. The explicit .then((m) => m.default) shape stays useful when one module re-exports several namespaces.

#Recipe: lazy-load via a route resolver

The second element of the tuple is a route resolver. Wire it into the route that owns the feature and the matching locale chunk loads before the component activates, so the view never renders a missing key or a flash of the fallback language. Only the active locale's chunk loads, not all of them.

quote.routes.ts
import { type Routes } from '@angular/router';
import { resolveQuoteTranslations } from './quote.t';

export const QUOTE_ROUTES: Routes = [
  {
    path: '',
    component: QuoteComponent,
    resolve: {
      // loads the active locale's chunk before the component activates
      translations: resolveQuoteTranslations,
    },
  },
];

This is the standard way to load a feature's strings. If you want the chunk to warm on link hover instead of at navigation, the @mmstack/router-core prefetch integration pairs with the resolver so a later navigation resolves instantly.

#Recipe: share Save and Cancel across features

Strings like Save, Cancel, Yes, and No belong in one place, not copied into every feature. Define them once in a shared namespace and export its createMergedNamespace factory. Any namespace built through that factory can read the common keys type-safely alongside its own.

common.namespace.ts
import { createNamespace } from '@mmstack/translate';

const ns = createNamespace('common', {
  yes: 'Yes',
  no: 'No',
  save: 'Save',
  cancel: 'Cancel',
});

export default ns.translation;
export type CommonLocale = (typeof ns)['translation'];
export const createCommonTranslation = ns.createTranslation;

// other namespaces build on this to reach 'common.*' keys type-safely
export const createAppNamespace = ns.createMergedNamespace;

A feature then builds on it with createAppNamespace in place of createNamespace. Its injected t now resolves both quote.pageTitle and common.save, both fully typed.

quote.namespace.ts
import { createAppNamespace } from '@org/common';

const ns = createAppNamespace('quote', {
  pageTitle: 'Famous Quotes',
  // ...quote's own keys
});

// t() now resolves both 'quote.pageTitle' and 'common.save'
export default ns.translation;

Register the common namespace's resolver at the top level of your route tree so the shared strings load before any feature that reads them. The tooling knows about this too: exported common keys live in their own file with no duplication, so mmtranslate round-trips them once.