Guides
Message format
One small syntax, powered by native Intl underneath: plurals, gender and formatting without learning full ICU.
Interpolation
Values format themselves by type: numbers via Intl.NumberFormat, dates via Intl.DateTimeFormat, strings as-is. A number reads the way the language writes it, so 2026 comes out as 2,026 in English: pass String(year) when the value is a label, like the year in a copyright line, and not a quantity. A missing param shows the placeholder literally and warns once naming the message, so it’s easy to spot and never a crash.
Formatters
Append :formatter (with optional /arg) to a param:
| Syntax | Result |
|---|---|
| {n:number} | Locale number: 1,234.56 / 1234,56 |
| {n:integer} | Rounded, no decimals |
| {n:percent} | 0.5 → 50% |
| {n:currency/EUR} | Currency with ISO code |
| {d:date/long} | dateStyle: short · medium · long · full |
| {d:time/short} | timeStyle variants |
Custom formatters plug in at instance creation. The full signature is (value, locale, arg?) => string, where locale is the active locale and arg is whatever follows the / in the placeholder:
Relative time, lists & units
The format surface covers the rest of modern Intl, still with zero dependencies:
relative: pass aDateand the unit picks itself against now (Intl.RelativeTimeFormatwithnumeric: 'auto', so you get yesterday, not 1 day ago); pass a number with an explicit unit (relative/hour).list: arrays localize viaIntl.ListFormat, conjunction by default,/orfor disjunction,/unitfor unit lists. Each item auto-formats per locale.unit: any CLDR unit id (kilometer,megabyte,liter…) throughIntl.NumberFormat.- Bad data never crashes a render, and never slips by either. An unknown format, an invalid unit, a placeholder missing its argument, a list that is not a list: each one warns once, naming the message, and falls back to
String(value).
Plurals and selects: one syntax
- Numbers match
=Nexact values first, thenIntl.PluralRulescategories (zero one two few many other). - Strings match their variant key, falling back to
other. #renders the number, locale-formatted. Variants nest placeholders freely.- Always include
otherin a plural block. It is the catch-all, so a block without it renders nothing for any count it does not list.verbaly checkfails on that, and also warns when a language needs forms your message lacks: Polish reachesfewandmanywhere English only needsoneandother. If a catalog reaches your app without passing through the build, from a lazy loader or a CMS, the runtime warns in the console naming the message instead of rendering an empty string quietly. A gender or role block is different: leavingotherout there is your choice, and Verbaly leaves it alone.
ICU escape-hatch
Have existing ICU MessageFormat strings, or need its exact syntax? Write ICU and Verbaly detects it automatically and parses it into the same engine, with zero extra dependencies.
- Auto-detected per message: no config, no marker. Native and ICU messages coexist in the same catalog.
- Supports
plural,select,selectordinal(real ordinal rules),{n, number/date/time, style},#,=N, nesting and'…'quoting. - Checked like any other message. A translation that flattened an ICU plural into plain text, or dropped a parameter from it, fails
verbaly checkexactly like a native one does. The same guard runs on what a machine translation returns and on what you import. - Prefer the native syntax by default; reach for ICU only when you need it.
- You only pay for it if you use it. Verbaly reads your catalogs at build time, and the ICU parser goes into your app only when one of your messages needs it. That is about 544 bytes, and there is nothing to switch on. Relative time works the same way: another 318 bytes that only travel when a message asks for them.
Escapes
| Write | Get |
|---|---|
| {{ | Literal { |
| }} | Literal } |
| || | Literal | inside variants |
| ## | Literal # inside variants |
In a catalog, a literal { or } is always written doubled. Text written in your code gets that for free, because extract doubles them for you; a catalog written by hand has to do it itself, translations included.
In rich messages (data-verbaly-rich or <Trans>) the numeric entity { also shows a literal brace: it decodes after parsing, so it never touches the placeholder syntax.