Internationalization
Every engine already formats numbers, dates, lists, and plurals for every locale through Intl, so @zerolith/i18n ships no locale data and no message runtime. It does three things around that:
- It decides which locale to use, and keeps it as a signal.
- It wraps the
Intlformatters so they follow that signal. - It compiles message catalogs ahead of time into plain functions, one module per locale, so a locale is one chunk and formatting a message is a string concatenation.
The locale
createLocale negotiates what was requested, an Accept-Language header on the server or navigator.languages in the browser, against the locales the application supports. The result is a signal, with the writing direction derived from it:
import { computed } from "zerolith";
import { createLocale, formatters } from "@zerolith/i18n";
const locale = createLocale({
supported: ["en", "de", "ar"],
// An Accept-Language header on the server,
// navigator.languages in the browser:
initial: request.headers.get("accept-language")
});
const format = formatters(locale.locale);
const euros = { style: "currency", currency: "EUR" } as const;
const total = computed(() => format.number(1234.5, euros));
console.log(locale.locale.get()); // "de", for "de-AT,de;q=0.9,en;q=0.5"
console.log(total.get()); // "1.234,50 €"
locale.set("ar-EG");
console.log(locale.direction.get()); // "rtl"Negotiation follows RFC 4647 lookup. Each requested range is tried in order of quality, and the first that finds a match wins. With ["en", "de", "pt-BR"] supported and en as the fallback:
| Requested | Result | Why |
|---|---|---|
de |
de |
An exact match |
de-AT |
de |
The request with its subtags dropped |
pt |
pt-BR |
A more specific supported locale of the same language |
fr-CA,de;q=0.8 |
de |
No match for fr-CA, so the next range by quality |
fr or * |
en |
Nothing matches, so the fallback |
The fallback is the first supported locale unless fallback names another, and set negotiates the same way, so the locale is always one the application supports. The signal is named locale, so hot replacement keeps it across an edit. applyLocale(locale) keeps <html lang dir> in step with it and returns the effect's disposer.
Formatters
formatters(locale.locale) wraps the Intl formatters. Each call reads the locale signal, so a computed value or a template binding that formats updates when the locale changes, and nothing else does. Instances are cached per locale and options, so formatting in a long list builds each formatter once.
| Call | Wraps |
|---|---|
number(value, options) |
Intl.NumberFormat, for numbers, percentages, units, and currency |
date(value, options) |
Intl.DateTimeFormat, for a Date or a timestamp |
relative(value, unit, options) |
Intl.RelativeTimeFormat, as in relative(-1, "day") |
list(values, options) |
Intl.ListFormat, joining with the locale's "and" or "or" |
plural(value, options) |
Intl.PluralRules, returning the category, such as "one" or "other" |
formatters also takes a fixed tag, as formatters("de"), for a server that renders each request in one locale.
Messages
Catalogs are JSON files, one per locale, whose values are MessageFormat 2, the Unicode standard that succeeds ICU MessageFormat: plain text with placeholders, functions such as :number and :datetime with their options, and .match for plural and other selection. This is messages/en.json:
{
"notes-count": ".input {$count :number}\n.match $count\n0 {{No notes yet}}\none {{One note}}\n* {{{$count} notes}}",
"saved-at": "Saved {$when :datetime dateLength=medium timePrecision=minute hour12=false timeZone=UTC}",
"welcome": "Welcome back, {$name}!"
}Compile the folder into modules:
npx zerolith-i18n messages --out src/messagesThe command compiles every <locale>.json in the folder into <locale>.ts, and writes an index.ts with the list of locales and the loaders that import one locale at a time. It compiles every catalog before it writes a file, so a catalog it cannot compile, or a file named other than a language tag, fails the command, naming the file, and leaves the output as it was. --js writes JavaScript with JSDoc types instead, for buildless projects. A key that some locales lack is reported, and --strict makes it fail the build.
Each message becomes a function named from its key in camel case, with an args object typed from its placeholders. A key that is a reserved word, such as delete or new, keeps its name as an export, and locale is refused, since every compiled module exports the locale it holds. A placeholder such as {$first-name} is an argument named first-name:
| A placeholder formatted with | Takes |
|---|---|
:number, :integer, :percent, :currency, or :offset |
A number |
:date, :time, or :datetime |
A Date or a timestamp |
| Anything else, or no function | A string or a number |
A formatter whose options are all literals, as in {$price :currency currency=EUR}, is created once per locale module, on first use, and shared by every message that formats the same way. Options that read an argument, such as currency=$code, find their formatter in a cache keyed by the options.
The application imports the functions it needs:
import { notesCount, savedAt, welcome } from "./messages/en.ts";
const saved = Date.UTC(2026, 9, 28, 9, 30);
console.log(notesCount({ count: 0 })); // "No notes yet"
console.log(notesCount({ count: 1 })); // "One note"
console.log(notesCount({ count: 12 })); // "12 notes"
console.log(savedAt({ when: saved })); // "Saved Oct 28, 2026, 09:30"
console.log(welcome({ name: "Ada" })); // "Welcome back, Ada!"Selection follows the specification: an exact numeric key beats a plural category, which beats *, and with several selectors the keys are compared left to right.
Loading one locale
The messages are a resource over the locale signal. Switching locales loads the next module, and everything that reads the messages updates once it arrives:
import { computed, resource } from "zerolith";
import { createLocale } from "@zerolith/i18n";
import { loaders, locales } from "./messages/index.ts";
const locale = createLocale({
supported: locales,
initial: navigator.languages
});
const messages = resource({
params: () => locale.locale.get(),
loader: async ({ params }) => loaders[params]?.()
});
const heading = computed(() => messages.get()?.notesCount({ count: 3 }) ?? "");
// Once de.ts has loaded, heading.get() is "3 Notizen".
// This loads en.ts, after which heading.get() is "3 notes".
locale.set("en");While the next locale loads, the resource keeps the messages it has, so the page never shows an empty heading in between. Nothing about the first locale stays in memory except what the engine caches for Intl.
Locales in the URL
In a routed application the locale can be a route segment: declare paths such as /:locale/notes/:id, pass params.locale to set, and build links with router.href as usual. Negotiating on the server and redirecting a bare / to /de/ keeps the URL the single source of truth, and every page can be shared in the language it was read in.