Guides
Catalogs and ICU messages
One JSON file per locale, ICU MessageFormat inside it.
One file per locale at locales/{locale}.json. The compiler reads it and never writes it.
{ "nav": { "home": "Home", "cart": "Cart" }, "cart": { "greeting": "Hi {name}, your cart is ready", "items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}", "total": "Total: {amount, number, ::currency/USD}", "updated": "Updated {at, date, medium}" }, "order": { "status": "{state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}" }, "terms": { "accept": "Read our <link>terms</link> before you continue." }}| Value | Result |
|---|---|
| Object | A level, unless it is a formatjs extract record: a string defaultMessage with only extract fields beside it is LZ1010 catalog-shape-invalid (Migrating from FormatJS). |
| String | A message: nav.home becomes m.nav_home() in module nav. |
Dotted key, "nav.home" |
Same path as the nested form; both together are LZ1011 duplicate-key. |
null |
Missing translation; takes the fallback chain. In the source locale it drops the message and is LZ1010 catalog-shape-invalid, since the source has nothing to fall back to; a target still carrying the key gets LZ3003 extra-translation. |
| Array | LZ1010 catalog-shape-invalid: flattening would yield one message per item. |
A file with {{ anywhere, or a key with a CLDR plural suffix, is i18next, converted in memory
(Importing i18next catalogs). The rest is ICU.
Arguments
Section titled “Arguments”| Written | Argument type |
|---|---|
{name} |
string | number |
{count, plural, ...} |
number |
{at, date, ...} |
Date | number |
{state, select, packing {...} shipped {...} other {...}} |
'packing' | 'shipped', branches minus other |
/** en: "Hi {name}, your cart is ready" */export declare function cart_greeting(args: { name: string | number }, opts?: MessageOptions): string/** en: "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" */export declare function cart_items(args: { count: number }, opts?: MessageOptions): string/** en: "Updated {at, date, medium}" */export declare function cart_updated(args: { at: Date | number }, opts?: MessageOptions): stringPlural and selectordinal
Section titled “Plural and selectordinal”{ "guests": "{count, plural, offset:1 =0 {Nobody is coming} =1 {Only you} one {You and # other} other {You and # others}}", "rank": "{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place"}if (n0 === 0) return `Nobody is coming`if (n0 === 1) return `Only you`switch ($plural1('en', n0 - 1, false)) { case 'one': return `You and ${$number1(l, n0 - 1, $f44136fa355b3678a)} other` default: return `You and ${$number1(l, n0 - 1, $f44136fa355b3678a)} others`}- Exact branches (
=0,=1) match first, against the value as passed. Intl.PluralRulespicks a category in the arm’s language (the literal'en'), from the value minus anyoffset.#renders that value in the visitor’s locale (l).
A locale falling back to the source gets English branches and categories, with its own digits.
| Rule | Severity | Cause |
|---|---|---|
LZ2004 |
error | A plural without other: the message is dropped, not guessed at. |
LZ2005 |
error | A select without other, dropped the same way. |
LZ3013 plural-category-unreachable |
warn | A branch the locale never selects: German zero passes syntax checks but never renders. Use =0. |
LZ3007 plural-category-incomplete |
warn | A category the locale selects and the message lacks (many, below). |
The many category
Section titled “The many category”warn LZ3007 plural-category-incomplete locales/es.json:1:23 es cart.items
es selects many for some values of {count}, and this plural has no branch for it.
fix add many {...} to {count, plural, ...} in locales/es.jsonSpanish, French, Italian and Portuguese select many for millions in current CLDR (Node 22:
new Intl.PluralRules('es').resolvedOptions().pluralCategories is ['many', 'one', 'other']).
Each plural with only one and other warns once per such locale.
- Add
many {# ...}mirroringother. - Or set
'plural-category-incomplete': 'off'until the catalog is clean, then turn it back on. - The i18next importer adds no
many:items_onebesideitems_otherwarns the same way. Additems_manyto clear it.
Select
Section titled “Select”{ "order": { "status": "{state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}" } }Derive the type from the signature: type Delivery = Parameters<typeof m.order_status>[0]['state'].
A translation dropping a branch warns (LZ3008); adding one the source lacks is an error (LZ3009).
Number and date styles
Section titled “Number and date styles”| Written | Intl options |
|---|---|
{n, number} |
{} |
{n, number, integer} |
{ maximumFractionDigits: 0 } |
{ratio, number, percent} |
{ style: 'percent' } |
{amount, number, ::currency/USD} |
the skeleton, resolved at build time |
{views, number, ::compact-short} |
{ notation: 'compact', compactDisplay: 'short' } |
{bytes, number, ::scale/1000} |
{}, with the value multiplied by 1000 first: Intl has no scale option |
{speed, number, ::unit/kilometer-per-hour} |
{ style: 'unit', unit: 'kilometer-per-hour' }; 90 renders 90 km/h |
{speed, number, ::measure-unit/length-meter per-measure-unit/duration-second} |
{ style: 'unit', unit: 'meter-per-second' }; 5 renders 5 m/s |
{at, date} or {at, date, medium} |
{ dateStyle: 'medium' } |
{when, time, short} |
{ timeStyle: 'short' } |
{when, date, ::yMMMd} |
{ year: 'numeric', month: 'short', day: 'numeric' } |
{when, time, ::jm} |
{ hour: 'numeric', minute: 'numeric' }; the locale picks the hour cycle, so en renders 2:05 PM and de 14:05 |
Under percent, Intl already multiplies by 100, so ::percent scale/100 renders 0.25 as 25%,
the same as ::percent.
A bare {amount, number, currency} fails: ICU carries no currency code.
error LZ2002 icu-style-unknown locales/en.json:14:11 en usd
"currency" is not a known number style.
fix ICU carries no currency code. Define formats.number.currency, or write ::currency/USD.Any other style name, like eur, must be defined in formats.number or formats.dateTime in
loclizr.config.ts.
Markup
Section titled “Markup”<link>terms</link> makes link a required argument; the message returns an array, not a string.
/** en: "Read our <link>terms</link> before you continue." */export declare function terms_accept<T>( args: { link: (chunks: readonly (string | T)[]) => T }, opts?: MessageOptions,): readonly (string | T)[]- Tags nest:
<b><i>full</i> terms</b>compiles toargs.b([args.i(['full']), ' terms']). - Translators may renest freely; only tag names are compared across locales (
LZ3010). - Tag attributes are not supported.
Partsfromloclizr/reactrenders the array (React).
Apostrophes
Section titled “Apostrophes”Quoting starts only when an apostrophe precedes {, }, <, or # inside a plural branch.
| Written | Renders |
|---|---|
Don't |
Don't |
'{' |
{ |
'' |
', always one literal apostrophe |
The record stores the canonical quoted form: Don't is recorded as Don''t.
Descriptions and placeholder notes
Section titled “Descriptions and placeholder notes”Translator notes go in locales/en.meta.json, never in en.json: a TMS (translation management
system) imports every leaf there as a string to translate, so a note would ship as copy
(Translation workflow).
{ "cart.items": { "description": "Badge under the cart icon on every page.", "placeholders": { "count": "Number of line items, not total quantity." } }, "order.status": { "description": "Chip in the order list. Past tense." }}Both fields land in the context record. A note for a key that no longer exists is LZ1015, and a
placeholder note for an argument the message does not take is LZ1022.
LZ3012 ambiguous-source fires when keys share source text and one lacks a description:
error LZ3012 ambiguous-source locales/en.json:5:14
2 keys share the source text "Cart" and one has no description. A translator sees one string with no way to tell the meanings apart.
app.cart locales/en.json:5:14 no description nav.cart locales/en.json:21:14 "Top navigation link that opens the cart page."
fix add descriptions in locales/en.meta.json: "app.cart": { "description": "" } or turn the rule down in loclizr.config.ts: severity: { 'ambiguous-source': 'warn' }