QUESTPIE
ClientI18n

Message catalogs

A message is a plain string or an object of plural forms. This page covers both, plus the placeholder rules, what t() returns when a value is missing, and every check the factory runs.

View markdown

A catalog is flat. "items.count" is one key with a dot in it, not a path into a nested object. Nest one and the factory reads it as a plural message, then throws because it has no one form.

const messages = {
	en: {
		greeting: "Hello {{name}}",
		"items.count": {
			one: "{{count}} item",
			other: "{{count}} items",
		},
	},
};

Placeholders

A placeholder is a name in double braces. {{name}} and {{ name }} mean the same thing. The name may hold letters, digits and underscores. {{user.name}} has a dot. It never matches, so it stays in the output as you wrote it.

A value that is missing leaves the placeholder in place. You see the hole instead of the word "undefined". The echo drops the spaces, so {{ name }} comes back as {{name}}.

CallResult
t("greeting", { name: "Ada" })Hello Ada
t("greeting", { name: null })Hello null
t("greeting", { name: undefined })Hello {{name}}
t("greeting", {})Hello {{name}}
t("greeting")Hello {{name}}

Only undefined counts as missing. Every other value goes through String(), which is why null prints. t() always returns a plain string. Nothing here parses HTML or renders a component.

Plural forms

one and other are required. zero, two, few and many are optional. Any other key throws.

t() reads params.count. It must be a number. Anything else is read as 1. Intl.PluralRules for the current locale then picks the category. If your object has no entry for that category, other is used.

Categories are per message, not per catalog, because locales disagree about them. English needs one and other. Slovak uses one for 1, few for 2 to 4, many for fractions and other for the rest. Declare what each locale needs.

const sk = {
	"items.count": {
		one: "{{count}} položka",
		few: "{{count}} položky",
		other: "{{count}} položiek",
	},
};

A plural message needs a count

t("items.count") returns {{ count }} item. With no params there is nothing to interpolate, and the absent count is read as 1. Pass {count} every time you call a plural message.

A key that is not in the catalog

t() returns the key itself. A typo shows up on the screen as items.cuont. That is ugly, and that is the point.

fallbackLocale names the catalog t() reads second. It defaults to the first entry in locales. Every catalog carries the same keys, though. So that second read can never find a message the first one missed, and setting it changes no output.

What the factory checks

createSimpleI18n validates everything before it returns. Every failure throws TypeError. Most messages name the locale or key at fault. The checks on locales and on messages as a whole have nothing to name.

RuleTypeScriptRuntime
At least one localeyesyes
Locale codes are uniquenoyes
Each code is a valid locale, with no paddingnoyes
locale is one of localesyesyes
fallbackLocale is one of localesyesyes
Every locale has a catalogyesyes
No catalog for an undeclared localeyesyes
Every catalog carries the same keysyesyes
A plural message has string one and otheryesyes
No unknown plural categorynoyes

The runtime pass is not a duplicate. It is what catches a catalog you fetched as JSON, or a call from plain JavaScript. The types were never there for either.

setLocale is the one check that fires later. Pass it a locale you never declared and it throws RangeError.

Catalogs are copied

The factory clones every catalog and freezes the copy. Editing your source object afterwards changes nothing the adapter reads.

There is also no method that swaps the messages out. A new catalog means a new adapter. In React that costs one line. I18nProvider rebuilds its context value when the adapter prop changes. Every consumer below rerenders.

Formatters are cached

Building an Intl formatter costs far more than using one. Each adapter keeps its own cache for dates, numbers, plural rules, relative times and display names. Each holds 32 entries, keyed by locale and options, and drops the least recently used first. A list that formats a thousand dates the same way builds one formatter.

The admin panel

@questpie/admin builds its adapter on this same factory. It fetches one locale at a time. So it fills the missing catalogs in first, then calls the factory. That is an Admin concern. The factory you call stays strict.

On this page