Skip to content
loclizr

One file per locale at locales/{locale}.json. The compiler reads it and never writes it.

locales/en.json
{
"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.

Written Argument type
{name} string | number
{count, plural, ...} number
{at, date, ...} Date | number
{state, select, packing {...} shipped {...} other {...}} 'packing' | 'shipped', branches minus other
src/loclizr/messages/cart.d.ts
/** 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): string
locales/en.json
{
"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"
}
src/loclizr/messages/_root.js (en arm)
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`
}
  1. Exact branches (=0, =1) match first, against the value as passed.
  2. Intl.PluralRules picks a category in the arm’s language (the literal 'en'), from the value minus any offset.
  3. # 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).
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.json

Spanish, 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 {# ...} mirroring other.
  • Or set 'plural-category-incomplete': 'off' until the catalog is clean, then turn it back on.
  • The i18next importer adds no many: items_one beside items_other warns the same way. Add items_many to clear it.
locales/en.json
{ "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).

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.

<link>terms</link> makes link a required argument; the message returns an array, not a string.

src/loclizr/messages/terms.d.ts
/** 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 to args.b([args.i(['full']), ' terms']).
  • Translators may renest freely; only tag names are compared across locales (LZ3010).
  • Tag attributes are not supported.
  • Parts from loclizr/react renders the array (React).

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.

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).

locales/en.meta.json
{
"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' }