@push.rocks/smarti18n
@push.rocks/smarti18n gives a TypeScript package its words in every language it speaks, and the numbers, money, dates and lists around them in the reader's formats.
- Each package keeps one typed catalog of messages in its source language, and translations are data.
- A wrong key, a missing parameter, or a translation that takes other parameters than its source is a compile error.
- The package also ships a country dataset with phone prefixes.
It has no runtime dependencies. It uses only Intl and runs in the browser, Node.js, Deno and Bun.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add @push.rocks/smarti18n
The package is published as native ESM and includes TypeScript declarations.
Messages in five steps
1. Define the catalog of your package, once, in its source language:
import { defineCatalog } from '@push.rocks/smarti18n';
export const messages = defineCatalog({
namespace: '@my/package', // the package name: one catalog per namespace
source: 'en',
messages: {
greeting: 'Hello {name}.',
counter: 'Step {current, number} of {total, number}',
rows: { message: '{count, plural, =0 {No rows} one {# row} other {# rows}}', description: 'Rows of a table' },
due: 'Due {amount, money} on {day, day, medium}',
help: 'Read the <link>guide</link>.',
retry: { message: 'Try again', description: 'The button after a failure' },
},
});
messages.addTranslation('de', () => import('./messages.de.js'));
2. Translate it. A base language is complete, and the compiler checks every entry against its source:
// messages.de.ts
import { messages } from './messages.js';
export default messages.translation('de', {
greeting: 'Guten Tag, {name}.',
counter: 'Schritt {current, number} von {total, number}',
rows: '{count, plural, =0 {Keine Zeilen} one {# Zeile} other {# Zeilen}}',
due: 'Fällig {amount, money} am {day, day, medium}',
help: 'Lesen Sie die <link>Anleitung</link>.',
retry: 'Erneut versuchen',
});
A regional variant is an overlay that changes only its own words and falls back to its base: messages.overlay('de-CH', { … }).
3. Create an instance for an audience (a page, a document preview, an e-mail), and wait until its language is loaded:
import { createI18n } from '@push.rocks/smarti18n';
const i18n = createI18n({ locale: 'de-DE' });
await i18n.ready;
ready resolves when the first switch commits: the one to locale, or a later setLocale() that overtook it. It rejects when no switch has committed and the latest one failed, for example with load_failed. It settles once and does not recover. After a refusal, call setLocale() again; once that commits, the instance is usable, and whenLoaded(catalog) waits for it rather than for ready. A caller that catches a failed setLocale() itself does not also have to handle ready.
4. Write messages. Keys and parameters are typed:
const t = i18n.bind(messages);
t('counter', { current: 2, total: 4 }); // 'Schritt 2 von 4'
t('rows', { count: 1234 }); // '1.234 Zeilen'
t('due', { amount: { minor: 123456n, currency: 'EUR' }, day: '2026-03-14' }); // 'Fällig 1.234,56 € am 14.03.2026'
t('retry'); // 'Erneut versuchen'
t('counter', { current: 2 }); // compile error: `total` is missing
t('cunter'); // compile error: no such key
t('help'); // compile error: the message "help" has tags: write it with rich()
rich() hands each tag's content to a function and returns the text around it as parts. The strings among the parts and chunks are plain, unescaped text. Put them into the page as text: as DOM nodes, or through a template that escapes, such as lit's html. Never join them into an HTML string:
const parts = t.rich('help', {
link: (chunks: Array<string | Node>) => {
const anchor = document.createElement('a');
anchor.href = '/guide';
anchor.append(...chunks); // strings become text nodes
return anchor;
},
});
paragraph.replaceChildren(...parts); // 'Lesen Sie die ', <a href="/guide">Anleitung</a>, '.'
// with lit: html`<p>${t.rich('help', { link: (chunks) => html`<a href="/guide">${chunks}</a>` })}</p>`
5. Switch the language. Every catalog's translations are loaded first. Then everything changes at once, and subscribers are told once:
const unsubscribe = i18n.subscribe(() => render());
await i18n.setLocale('en-GB'); // true once committed; false if a later switch overtook it
A switch keeps formats set with setFormats() while it was loading.
What the compiler checks
- Messages. A message that does not read is an error at the catalog's namespace, with the key and the reason, for example
the message "a" does not read: the plural argument n has no other branch. - Keys and parameters.
t()takes only the catalog's keys. Each key takes exactly the parameters its message names, with the types in the table below, and a message without parameters takes no second argument. - Translations. A
translation()must meet every rule below; otherwise the error says which keys are wrong, for examplethe translation lacks the keys: retry, or one property per key that takes other parameters than its source:- it lacks no key;
- it has no key the source has not;
- every message takes the same parameters, kinds, select keys and tags as its source.
- Overlays and overrides have no extra keys and take the parameters of their source.
The same checks run at runtime when a translation is made, so data the compiler did not see is refused too. For JSON from a translation tool, translationFromData(locale, record) throws I18nError invalid_translation and lists every problem.
Cost: 2,000 messages with a complete translation and 2,000 typed calls add about 2.5 s to a tsbuild check (measured on TypeScript 6.0.3).
Message grammar
Messages are written in a typed subset of ICU MessageFormat 1, the syntax translators and translation tools know.
| Syntax | Parameter type | Written as |
|---|---|---|
{name} |
string |
as is |
{n, number}, {n, number, integer}, {n, number, percent} |
number | bigint |
in the number locale |
{m, money}, {m, money, code | accounting | signed} |
IMoney ({ minor, currency }) |
from whole minor units, in the currency the value carries; signed writes a plus for a positive amount and no sign for zero |
{d, date, short | medium | long | full}, {d, time, …} |
Date | number (an instant) |
in the date locale and the instance's time zone |
{d, day, short | medium | long | full} |
TIsoDate ('2026-03-14') |
as that calendar day in every time zone |
{m, month}, {m, month, short} |
TIsoMonth ('2026-03') |
März 2026, Mar 2026 |
{xs, list}, {xs, list, or | unit} |
readonly string[] |
a, b und c |
{r, relative}, {r, relative, short | narrow} |
IRelativeTime ({ value, unit }) |
gestern, in 3 hours |
{n, plural, =0 {…} one {# …} other {…}}, {n, selectordinal, …} |
number |
the branch the language's rules choose; # is the number |
{k, select, a {…} b {…} other {…}} |
'a' | 'b' |
the branch of the key |
<tag>…</tag> |
a function in rich() |
whatever the function makes of the tag's content |
Rules:
- Branches. A plural and a select need an
otherbranch. Plural selectors are CLDR categories (zero one two few many other) or=n. - Plural rules. A plural is chosen by the rules of the language its message is written in. A message that falls back to the English source is chosen by English rules, whatever the reader's language.
- Apostrophes:
- an apostrophe before
{,},<,>or#quotes up to the next apostrophe, so'{'braces'}'writes{braces}; ''writes an apostrophe;- any other apostrophe is text, so
the supplier's nameneeds no escaping; - a quoted run ends at the next apostrophe, so
'{it'''s'}'writes{it's}.
- an apostrophe before
- Tags.
<starts a tag only before a letter, soa < bis text. - One name, one kind. A name used as
{n, number}and in{n, plural, …}is one number. Any other second kind is an error. - Names. Argument names are letters, digits and
_, not starting with a digit.__proto__is no argument name, because an object literal{ __proto__: … }sets the prototype instead of passing the argument. Names such asconstructorortoStringare names like any other. #is the number only inside a plural; elsewhere it is text.
Languages, chains and formats
-
The chain.
de-ATlooks its words up inde-AT, thende, then the catalog's source. Extensions such as-u-nu-latnare dropped for the lookup. -
Offered languages.
- By default, every language a defined catalog is written or translated in is offered; a host passes its own list as
locales. setLocale()refuses a language outside that list (unavailable_locale) instead of silently showing another.- Use
negotiateLocale(navigator.languages, i18n.available)to pick one.
- By default, every language a defined catalog is written or translated in is offered; a host passes its own list as
-
Formats apart from the language.
- A region in the language tag does not decide separators: CLDR writes
€1,234.56foren-DE. formats: { number: 'de-DE' }writes German numbers in an English interface, andsetFormats()changes them later.timeZonedecides how instants are written.
- A region in the language tag does not decide separators: CLDR writes
-
Money carries its currency; no locale implies one.
- Minor units are whole numbers, and the digits per currency come from CLDR (EUR 2, JPY 0, BHD 3).
- Amounts are formatted from an exact decimal string, never through a float.
- This needs
Intl.NumberFormatv3 (ECMA-402 2023), which formats a numeric string as a decimal. Where the runtime would read the string as a float,money()throwsI18nErrorunsupported_runtimeinstead of rounding. sign: 'exceptZero'(thesignedstyle) writes+1.234,56 €and0,00 €, never+0,00 €.
-
Host overrides.
i18n.override(catalog, 'de', { retry: 'Nochmal' })replaces a component's words in one language. It is checked like an overlay. -
Direction.
i18n.directionisrtlfor a language whose likely script is written right to left (ar,he,fa, …). -
The source instance.
sourceI18nwrites every catalog in its source language and is ready at once. It is for logs and for errors developers read.createSourceI18n({ formats, timeZone, createFormats })makes another source instance, in formats of its own: a library's words while no host provided a language. It offers no other language.
-
Your own formats.
createFormats: (settings) => new MyFormats(settings)(forcreateI18n()andcreateSourceI18n()) makes everyFormatsan instance writes in:i18n.formatand the numbers, money and dates inside messages, the#of a plural included. A subclass ofFormatscan, for example, write numbers without grouping:import { createSourceI18n, Formats } from '@push.rocks/smarti18n'; class UngroupedFormats extends Formats { public override intlNumberFormat(options: Intl.NumberFormatOptions): Intl.NumberFormat { return super.intlNumberFormat({ useGrouping: false, ...options }); } } const neutral = createSourceI18n({ createFormats: (settings) => new UngroupedFormats(settings) }); neutral.t(messages, 'counter', { current: 1234, total: 5000 }); // 'Step 1234 of 5000'
Formatting outside messages goes through i18n.format (a Formats): number, money, instant, day, month, list, relative and displayName. A bound translator's t.format is the formats its catalog's messages write numbers and dates in, so the two always agree: the instance's format, and for a source instance the formats of the catalog's source language. Lists, relative times and display names inside a message are in the language that message is written in.
Late catalogs
A catalog defined after the language was set, for example in a lazily imported view, is loaded on request. Its words are refused (not_loaded) until await i18n.whenLoaded(catalog), so no screen shows its source words first. Words asked for before i18n.ready resolves are refused with not_ready: before the first switch commits, whichever switch that is.
i18n.isLoaded(catalog) tells synchronously whether a catalog's words can be asked for now: true once a language has committed and the catalog's translations along its chain are loaded, false before the first commit and while a translation is still to load. A renderer that must not show source words first can wait on whenLoaded() only when isLoaded() is false, and otherwise stay synchronous.
One registry for every copy
The catalogs are kept in a registry on globalThis, under Symbol.for('@push.rocks/smarti18n/registry'). The key never changes; the registry carries its protocol version inside. Every copy of this package in a page or process uses it, so a catalog defined by one copy is known to every I18n of every other copy, as long as the copies speak the same registry protocol. This release speaks protocol 1. This covers the case where two packages depend on different versions of @push.rocks/smarti18n.
- A namespace is one catalog across all copies: defining it twice is
duplicate_namespace, whichever copy defines it. - A copy that meets a registry of another protocol refuses to define or list catalogs (
incompatible_registry) instead of keeping its own apart. Two registries would leave the instances of each copy unaware of the other copy's catalogs. - One copy is still best: run
pnpm dedupeso that every package resolves the same version. - An
I18nErrorof one copy is not aninstanceoftheI18nErrorclass of another copy. Readerror.code.
Checks for your test suite
import { verifyCatalog } from '@push.rocks/smarti18n';
import { messages } from '../ts/messages.js';
tap.test('the words are complete in every language', async () => {
const findings = await verifyCatalog(messages, { identicalAllowed: ['IBAN'] });
expect(findings.filter((finding) => finding.severity === 'error')).toEqual([]);
});
verifyCatalog() loads every registered translation and reports:
- Errors:
syntax,missing,extraandsignature;plural_categories, for example a Polish plural withoutfewandmany. Exact forms count for a category they cover whole: English=1 {…} other {…}needs noone, because Englishoneis 1 alone. French=0and=1do not coverone, which also holds fractions such as 1.5.otheris always needed;overlay_without_baseandload_failed.
- Warnings:
unused_plural_categories;identical: a translation that is its source word for word;length: 60 % longer than its source;no_description: a one-word source message without a note for translators.
Pseudo-locales
With pseudo: true, two generated languages test layouts without a translator:
en-XAaccents every letter, lengthens each message by 40 % and brackets it:⟦Ĥéļļö Ada.···⟧. Clipped, glued-together and untranslated text shows at a glance. What the caller passes stays as it is.ar-XBturns every run of words right to left, anddirectionisrtl.
Country data
What it exports
ICountryCode
export interface ICountryCode {
code: string;
name: string;
phonePrefix: string;
}
Each record contains:
code: the two-letter country or territory code, such asDE,US, orGBname: the display name for the country or territoryphonePrefix: the international dialing prefix without a leading+, such as49for Germany
Some territories do not have their own dialing prefix in the dataset. In those cases, phonePrefix is an empty string.
countryCodeArray
export const countryCodeArray: ICountryCode[];
countryCodeArray contains more than 200 country and territory records.
Usage
Import The Dataset
import { countryCodeArray } from '@push.rocks/smarti18n';
Find A Country By Code
import { countryCodeArray } from '@push.rocks/smarti18n';
const germany = countryCodeArray.find((country) => country.code === 'DE');
console.log(germany);
// { code: 'DE', name: 'Germany', phonePrefix: '49' }
Build Country Select Options
import { countryCodeArray } from '@push.rocks/smarti18n';
const countryOptions = countryCodeArray.map((country) => ({
value: country.code,
label: country.phonePrefix
? `${country.name} (+${country.phonePrefix})`
: country.name,
}));
Create A Fast Lookup Map
import { countryCodeArray } from '@push.rocks/smarti18n';
const countriesByCode = new Map(
countryCodeArray.map((country) => [country.code, country])
);
const unitedStates = countriesByCode.get('US');
console.log(unitedStates?.phonePrefix);
// '1'
Filter Countries With Phone Prefixes
import { countryCodeArray } from '@push.rocks/smarti18n';
const countriesWithDialingPrefixes = countryCodeArray.filter(
(country) => country.phonePrefix.length > 0
);
Runtime Compatibility
@push.rocks/smarti18n is native ESM with no runtime dependencies, and its tests run in Node.js, Chromium, Deno and Bun.
The message runtime is about 7.4 KB minified and gzipped, most of it the English sentences of its errors. The country dataset is tree-shaken away when it is not imported.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.