2026-09-26 07:53:38 +00:00
…
2026-09-26 07:53:38 +00:00
2026-09-26 07:53:38 +00:00
…
2026-09-26 07:53:38 +00:00
…

@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 example the 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 other branch. 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 name needs no escaping;
    • a quoted run ends at the next apostrophe, so '{it'''s'}' writes {it's}.
  • Tags. < starts a tag only before a letter, so a < b is 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 as constructor or toString are names like any other.
  • # is the number only inside a plural; elsewhere it is text.

Languages, chains and formats

  • The chain. de-AT looks its words up in de-AT, then de, then the catalog's source. Extensions such as -u-nu-latn are 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.
  • Formats apart from the language.

    • A region in the language tag does not decide separators: CLDR writes €1,234.56 for en-DE.
    • formats: { number: 'de-DE' } writes German numbers in an English interface, and setFormats() changes them later.
    • timeZone decides how instants are written.
  • 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.NumberFormat v3 (ECMA-402 2023), which formats a numeric string as a decimal. Where the runtime would read the string as a float, money() throws I18nError unsupported_runtime instead of rounding.
    • sign: 'exceptZero' (the signed style) writes +1.234,56 € and 0,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.direction is rtl for a language whose likely script is written right to left (ar, he, fa, …).

  • The source instance. sourceI18n writes 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) (for createI18n() and createSourceI18n()) makes every Formats an instance writes in: i18n.format and the numbers, money and dates inside messages, the # of a plural included. A subclass of Formats can, 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 dedupe so that every package resolves the same version.
  • An I18nError of one copy is not an instanceof the I18nError class of another copy. Read error.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, extra and signature;
    • plural_categories, for example a Polish plural without few and many. Exact forms count for a category they cover whole: English =1 {…} other {…} needs no one, because English one is 1 alone. French =0 and =1 do not cover one, which also holds fractions such as 1.5. other is always needed;
    • overlay_without_base and load_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-XA accents 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-XB turns every run of words right to left, and direction is rtl.

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 as DE, US, or GB
  • name: the display name for the country or territory
  • phonePrefix: the international dialing prefix without a leading +, such as 49 for 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.

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.

S
Description
A package for internationalization (i18n) that provides utilities for dealing with international phone number prefixes, country codes, and names.
Readme
809 KiB
Languages
TypeScript 100%