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.
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}}.
| Call | Result |
|---|---|
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.
| Rule | TypeScript | Runtime |
|---|---|---|
| At least one locale | yes | yes |
| Locale codes are unique | no | yes |
| Each code is a valid locale, with no padding | no | yes |
locale is one of locales | yes | yes |
fallbackLocale is one of locales | yes | yes |
| Every locale has a catalog | yes | yes |
| No catalog for an undeclared locale | yes | yes |
| Every catalog carries the same keys | yes | yes |
A plural message has string one and other | yes | yes |
| No unknown plural category | no | yes |
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.
High-frequency events
A noisy channel will outrun the component reading it. Bound the state you keep on the client, and publish transitions instead of frames.
Admin
The admin is a data workspace generated from your schema. The people who own the data get a real interface, instead of a database client or a spreadsheet that drifts.