Skip to main content

@ttoss/i18n-core

Framework-agnostic i18n for any JavaScript runtime — Lambdas, HTTP servers, workers, MCP servers — on the same FormatJS/ICU stack as @ttoss/react-i18n.

Installation​

pnpm add @ttoss/i18n-core
pnpm add -D @ttoss/i18n-cli

The idea: produce references, render at the edge​

Code that produces text usually does not know who will read it: a core function throwing a validation error, or a job notifying an ad account three people share. So it does not format a string. It returns a message reference — plain JSON that can be returned, stored in a database or put on a queue — and whatever knows the reader renders it: the React tree, an HTTP/GraphQL/MCP boundary, or a channel sender.

Usage​

Declare messages​

Messages are declared with defineMessages, exactly as in React. @ttoss/config's build and Jest presets inject the ids; ttoss-i18n extracts them.

// src/notifications.persisted.messages.ts
import { defineMessages } from '@ttoss/i18n-core';

export const messages = defineMessages({
budgetExhausted: {
// An explicit id: this reference is stored, so its id must not change
// when the copy is edited.
id: 'notifications.budgetExhausted',
defaultMessage:
'Campaign {campaign} spent {amount} and was paused on {date}.',
description: 'Budget exhausted notification body',
},
});

Hashed ids are fine for text rendered immediately. A reference that is persisted needs an explicit id: a content-hash id changes whenever the source text changes, which would orphan every stored reference. Keep those messages in *.persisted.messages.ts files: @ttoss/eslint-config turns formatjs/no-id off there (and only there), and ttoss-i18n --explicit-ids '**/*.persisted.messages.ts' fails extraction when one of them relies on a hashed id.

Produce a reference​

import { fmt, msg } from '@ttoss/i18n-core';

const body = msg(messages.budgetExhausted, {
campaign: 'Black Friday',
amount: fmt.currency({ value: 1234.5, currency: 'USD' }),
date: fmt.date({ value: new Date(), timeZone: 'America/Sao_Paulo' }),
});

await db.notifications.insert({ body }); // plain JSON

fmt.* defers formatting to render time, so the amount is formatted in the reader's locale while keeping its own currency and time zone: US$ 1.234,50 for a pt-BR reader, $1,234.50 for an English one.

FormatterRenders
fmt.currency({ value, currency })An amount in that currency
fmt.number({ value, options? })A number; options is JSON-safe
fmt.percent({ ratio, maximumFractionDigits? })0.125 → 12.5%
fmt.date({ value, timeZone?, dateStyle?, timeStyle? })A date and/or time in that time zone
fmt.relativeTime({ value })yesterday, in 3 hours

A value may also be a string, number, boolean, null or another reference, which renders in the same locale.

Render at the edge​

import { createCatalog } from '@ttoss/i18n-core';

const catalog = createCatalog({
supported: ['pt-BR', 'en'],
fallback: 'pt-BR',
defaultLocale: 'pt-BR', // the language your defaultMessages are written in
load: async (locale) => {
return (await import(`../i18n/compiled/${locale}.json`)).default;
},
});

const i18n = await catalog.getI18n(
user.locale ?? request.headers['accept-language']
);

i18n.render(body); // plain text: WhatsApp, push, logs
i18n.renderHtml(body); // email: values escaped, <b>/<i>/<p>/<br>… kept

createCatalog loads each locale once per process and retries a load that failed. createI18n({ locale, messages, defaultLocale }) is the same thing without the cache, and both return a full FormatJS IntlShape, so formatMessage, formatNumber and the rest are there too. render passes a plain string through unchanged, so rows written before references existed keep rendering.

Negotiate a locale​

import { negotiateLocale } from '@ttoss/i18n-core';

negotiateLocale({
requested: 'fr-CA,fr;q=0.9,es;q=0.8', // a preference, a list, or an Accept-Language header
supported: ['en', 'pt-BR', 'es'],
fallback: 'en',
}); // → 'es'

For each requested locale, in preference order: an exact match, then the locale with subtags dropped (pt-BR → pt), then any supported locale of the same language (pt-PT → pt-BR). fallback applies only when nothing matches.

Localized errors​

import { isLocalizedError, LocalizedError, msg } from '@ttoss/i18n-core';

throw new LocalizedError({
code: 'META_ACCESS_LOST',
message: msg(messages.accessLost, { account: account.name }),
});

// at the boundary
if (isLocalizedError(error)) {
return { code: error.code, message: i18n.render(error.messageRef) };
}

code is for machines and never changes with the copy; messageRef is rendered in the request locale; error.message holds the source text, for logs. isLocalizedError checks the shape (code string plus messageRef), not the class, so an errors package that must stay dependency-free can produce compatible errors, and errors that crossed a bundle boundary are still recognized.

Boundary adapters​

These render a thrown LocalizedError at the edge, so producers never need a locale:

  • @ttoss/appsync-api: createAppSyncI18nMiddleware
  • @ttoss/http-server: i18nMiddleware
  • @ttoss/http-server-mcp: createGatedToolRegistrar({ i18n })
  • @ttoss/react-i18n: LocalizedText / useMessageRef

Testing with Jest​

@formatjs/intl ships ESM only. If your Jest config does not transform node_modules, let it transform FormatJS:

transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?(@formatjs|intl-messageformat))'],

API​

ExportWhat it is
defineMessage, defineMessagesFormatJS authoring, re-exported
msg(descriptor, values?)Create a MessageRef; throws on a descriptor without id or text
isMessageRef(value)Structural check, survives JSON
fmt.*, isFormatValue(value)Deferred formatting
createI18n({ locale, messages, defaultLocale?, onError? })IntlShape plus render, renderHtml, formatValue
createCatalog({ supported, fallback, load, defaultLocale?, onError? }){ getI18n, negotiate, supported, fallback }
negotiateLocale({ requested, supported, fallback })Best supported locale
LocalizedError, isLocalizedError(error)Errors with a stable code and a reference
DEFAULT_LOCALE'en', the source locale of every @ttoss/* package