Skip to content

0012. Keep languages, formats, currencies, units and countries independent, and localize at the edges

  • Status: accepted
  • Date: 2026-10-26

GuestCraft serves staff and guests who speak different languages, properties in different countries, and amounts in several currencies. The research report i18n-research.md verified the facts and the libraries; the ones that shape the code are these.

  • Currencies. A booking that Channex delivers carries its own currency, and a rate plan has its own, so an amount is meaningless without its currency (ADR 0014).
  • Content and guest language. Channex accepts content in one language only. The guest’s language in a booking revision is optional and has no documented format (ADR 0013).
  • Decimals. ISO 4217 and Unicode CLDR disagree on the decimals of some currencies: HUF has two in ISO 4217 and none in CLDR, so Intl.NumberFormat shows 12345.50 HUF as “12 346 Ft”.
  • Units.
    • ECMA-402 sanctions 45 units, among them acre and hectare, but neither square metres nor square feet: Intl.NumberFormat rejects square-meter with a RangeError (Node 24, ICU 78, CLDR 48).
    • CLDR has the localized patterns, such as “{0} m²”, “{0} pi²” in French and “{0} stopy kw.” in Polish. It also has the preferences by usage: floor area in square feet in the United States, the United Kingdom, Canada and Myanmar, in square metres elsewhere.
    • The TC39 proposal for smart unit preferences is at stage 1.
  • Runtimes.
    • Node 24 and current browsers implement the Intl Locale Info API, standard in ECMA-402 since its 13th edition (2026).
    • Hermes, the engine of the mobile app, registers only Collator, DateTimeFormat, NumberFormat and getCanonicalLocales in the version that Expo SDK 58 uses. There is no Intl.Locale, PluralRules, ListFormat or DisplayNames.
  • The core. It reads no ambient state: Biome rejects Intl in packages/core (structure review 2, N02). @guestcraft/i18n is MIT and may not import the AGPL core, not even a type (ADR 0004).

The analysis (chapter 6) already states the principle: language, currency, time zone and country are independent, and none is deduced from the others.

Six independent settings. None is ever derived from another; a default may be suggested, never imposed.

Setting Values Belongs to
Language of text a canonical BCP 47 tag (LanguageTag) a user, a guest, a text, a template
Formatting conventions a language and a region (en-GB), with the person’s hour cycle, first day of the week and measurement system a user, or a document’s recipient
Time zone IANA (ADR 0007) a property, for stays, business dates and deadlines; a person only to show them instants
Currency ISO 4217 (CurrencyCode) every amount; a property has a base currency (ADR 0014)
Country ISO 3166-1 alpha-2 (CountryCode) a property, an address, a document, a nationality; it chooses the country pack
Units the reader’s preference by usage: floor area, furniture, distance a user, or derived from the reader’s region
  • A person’s settings are a LocalePreferences value in the core. The adapters build the locale string for Intl from it, such as en-GB-u-hc-h23-ms-metric; extensions are never stored as part of a language.
  • A property has a country, a time zone, a base currency, the languages offered to guests and the language of its content, each chosen explicitly.
  • An organization has the languages it enables and a reporting currency.

Canonical inside, localized at the edges.

  • Storage, domain and API use only canonical values:
    • codes (IT, EUR, pt-BR);
    • amounts as integer minor units in the core and as decimal strings with the ISO decimals in the API;
    • ISO 8601 dates;
    • measures as integers of square centimetres and millimetres in the core, and as a decimal string with an explicit unit in the API.
  • Only what people read is localized: the UI, emails, PDFs, SMS, and the title and detail of problem details (ADR 0013). The API never returns a formatted value instead of a canonical one.

Where the code lives.

  • core/shared, pure and without Intl:
    • CountryCode: the 249 codes of ISO 3166-1, plus XK;
    • LanguageTag, which refuses codes that name no language (und) and deprecated ones (iw), with lookup, which chooses among available translations as RFC 4647 does but never drops a script (zh-Hant never becomes zh);
    • Money and CurrencyCode;
    • ExchangeRate, Conversion and convert, and roundToIncrement for cash (ADR 0014);
    • Area and Length;
    • LocalePreferences.
  • packages/i18n (MIT), on the public wire formats only, so that the SDK, the apps and any integrator can use it without the AGPL core:
    • languages:
      • negotiation with CLDR’s language matching from a pinned matcher, which Accept-Language exclusions (q=0) can veto. It chooses the catalog language. As CLDR’s distances say, a close language in another script comes before another language: zh-TW gets zh-Hans when there is no zh-Hant;
      • formattingLocale, which keeps the region of the same preferences for numbers, dates and measures: en-GB for a reader of the en catalog. The most wanted regional preference wins, and a generic one such as en never hides it;
      • canonicalization of tags that come from outside, such as EN or pt_BR. It refuses names such as English, codes that CLDR does not know, and a country code such as CH that would name another language;
      • text direction;
    • formatting:
      • money with the currency’s usual decimals unless the amount has more significant ones, so an amount is never rounded for display. The default is the locale’s symbol, since CLDR writes other currencies unambiguously (US$, CA$); inputs and views that mix currencies show the ISO code;
      • dates and stays from Temporal values, instants always with an explicit time zone;
      • areas, furniture sizes and distances in the reader’s unit, with CLDR’s patterns, parent locales and thresholds, rounded half away from zero as Intl rounds;
      • lists, and names of countries, languages and currencies;
    • input: strict reading of the numbers people type, by the locale’s conventions and never by a guess. Where the point neither groups nor separates decimals, as in French, a point before exactly three digits is refused, because it could group thousands;
    • catalogs: the message catalogs and their tools (ADR 0013);
    • runtimes: missingIntlFeatures(), which says what a runtime lacks.
  • Country packs (countries/<id>): rules, documents, reports, legal texts and the presentation of prices per jurisdiction, behind core ports (analysis, chapter 6). A pack never formats a number or a date: CLDR does.
  • Exchange rates, content translations and problem details: ADR 0014 and ADR 0013.

Measures.

  • Square centimetres and millimetres keep every value people type, as the core’s property test shows: up to two decimals in feet or square feet, one in inches, four in square metres. A value with more decimals than its unit reads back is refused (too_precise), as an amount with too many decimals is.
  • Conversions in the core, and convertArea and convertLength in i18n, round half to even. What people see rounds half away from zero.
  • The displayed precision is a choice of the view.
  • Booking.com’s content API takes room sizes in sqm or sqft. Channex has no room size field, so room areas serve GuestCraft’s own pages until a channel adapter maps them.

Runtimes.

  • @guestcraft/i18n uses the runtime’s Intl. On Hermes the mobile app loads the @formatjs polyfills first, for:
    • getCanonicalLocales, Locale, PluralRules, NumberFormat, DateTimeFormat, DisplayNames and ListFormat;
    • in that order, and only for the published languages.
  • A release build may not ship until missingIntlFeatures() returns nothing on real iOS and Android builds. Node and browsers load no polyfill.
  • Without the polyfill, Hermes reads the decimal strings given to NumberFormat as binary floats, so amounts above 2^53 would lose digits; the capability check probes exactly this.
  • The catalog loaders import JSON with import attributes. The first Expo spike checks that Metro supports them before more code depends on them.

Data, and their licenses.

  • CLDR data are generated into small, versioned files, with Unicode’s notice (Unicode-3.0):
    • the generator keeps each language and the regional variants that differ from their parent, such as en-001 and de-CH;
    • CLDR’s parent locales find the rest: en-AU reads en-001;
    • a test checks that the data come from the pinned CLDR version and cover every catalog and guest language.
  • Everything else comes from the runtime’s CLDR, whose version varies by runtime. The nightly matrix on several Node.js versions is the signal of drift.
  • When addresses arrive:
    • Google’s libaddressinput metadata (CC-BY-4.0) are taken as dated snapshots, with attribution;
    • Debian’s iso-codes (LGPL-2.1) never go into an MIT package;
    • @shopify/address, now deprecated, is not used.
  • The tables of accepted codes are for new operations. A record keeps the code and the decimals it was made with, so that a withdrawn currency never makes old documents unreadable.
  • Rules implemented both in the core and in i18n, such as unit conversions, run the same cases from @guestcraft/testing/cases.

Search. Collations and full-text search are decided with the search features. Within these limits:

  • ICU collations come from an allowlist, never a name built from a request;
  • nondeterministic collations cannot be used with LIKE on PostgreSQL 17;
  • unaccent, and a text search configuration per language, exist on Neon and Supabase.

Rules for every pull request.

  • Messages.
    • No sentence is built by concatenation; plurals and selections are ICU arguments.
    • Interpolated user data is bidi-isolated.
    • Layouts use logical CSS properties and no fixed widths for text.
  • Money.
    • An amount is never shown without its currency, and its currency never comes from the language or the region.
    • No amount is rounded for display.
    • No currency is converted implicitly.
  • Settings. No setting is deduced from another, and no language from a name, a nationality, a phone prefix, a card or a currency.
  • Sources. Source files never contain invisible or bidirectional control characters: they write escapes, and a test checks every file.
  • The core stays testable without Intl, and its results do not depend on a runtime’s CLDR version.

  • @guestcraft/i18n depends on the runtime’s Intl, which the capability check makes explicit.

  • Adding a language means:

    1. its catalog;
    2. its loader and its CATALOG_LANGUAGES entry, or its GUEST_LANGUAGES entry for guest pages;
    3. its CLDR area patterns, regenerated with pnpm i18n:cldr.

    A missing loader does not compile, and a test fails if the catalog or the patterns are missing.

  • The API gives integrators no formatted value. That is deliberate: they use @guestcraft/i18n or their own formatting.

  • Format in the core with a locale argument. Rejected: the results would depend on each runtime’s CLDR data, and the core would import Intl.
  • Derive currency or units from the language. Rejected: a German speaker in Zürich pays in CHF, and a guest from the UK may prefer square metres.
  • Lookup instead of best fit to choose a UI language. Rejected for the interface: lookup turns zh-TW into zh and misses pt-PT for pt-BR. The matcher’s data are pinned, so the choice is reproducible. Content selection in the core uses lookup, which never drops a script.
  • ICU4X for units. Rejected for now: its unit converter is experimental, and two area units do not justify a WASM dependency.
  • One MIT package for decimal arithmetic and unit factors, imported by the core and by i18n. Rejected for now. It would be the core’s only workspace dependency, for about 80 lines that the shared conformance cases keep in step: every unit that both implement has a case, which a test checks.
  • Area units in the catalogs, translated by hand. Rejected: CLDR already has them, correct in every language.

Amendment: 2026-10-29 — Published UI language

Section titled “Amendment: 2026-10-29 — Published UI language”

Only English is published as a staff UI catalog. CATALOG_LANGUAGES contains en alone; the generic negotiation and catalog machinery remains available for future languages. A future staff language also needs negotiation in the web app’s request configuration and a formatting locale chosen with formattingLocale (i18n README). Guest language data and GUEST_LANGUAGES, regional formats, currencies and units remain independent of the published UI catalog. Existing it UI cookies resolve to English.

Regional formatting implementation (2026-10-29)

Section titled “Regional formatting implementation (2026-10-29)”

Choosing the tag. The staff request uses englishFormattingLocale, which builds en-<region> from the reader’s Accept-Language in weight order: it-IT becomes en-IT, and de,en-US;q=0.7 becomes en-DE, because the reader’s region matters and the text is English anyway. When every region is refused, or the server’s ICU does not resolve the region for both numbers and dates, the tag is en-001, world English: never an implicit US English. The rule, its exclusions and its examples are stated once, in the i18n README. The generic multilingual formattingLocale remains public API.

The HTML language, next-intl/use-intl and React Aria all receive the server’s tag. Legacy UI-language cookies are ignored; reload and appearance saves retain the header’s regional signal. A person’s explicit formatting setting takes precedence in the design but remains planned: no selector or persistence is implemented.

The browser’s data. A browser can lack data that the server’s ICU has. Guessing them from the user agent was rejected: a user agent does not say which ICU data the browser has. Instead, the root layout sends the tag with a sample: a short date and a number, as the server writes them. LocaleProvider renders the server’s tag first, so hydration matches. Right after mount, before the browser paints, it checks the browser’s own data:

  • its number and date formatters resolve the tag’s region, not a parent such as en;
  • they write the sample the same way.

If either check fails, the provider switches to en-001 and sets it on <html lang>. Desk screens format dates, numbers and money only after their data load, so they render with the switched tag. Server-rendered HTML carries no locale-formatted values: page titles and headings are plain catalog text, and catalog counts in server components stay below the thousands, where every English region writes the same digits.

What this guarantees, and what was measured on 2026-10-29:

  • Node 24.22.1 / ICU 78.3, Chromium 156 and Firefox 157 format all 124 English regions that Node resolves identically, so they keep every tag.
  • Playwright’s WebKit 27.2 build bundles no ICU: on Ubuntu 24.04 it uses the system ICU 74.2. That WebKit resolves 17 of those regions to en, including en-IT, en-ES and en-FR. It writes en-CH’s number with a typographic apostrophe and en-ZA’s with other separators. These 19 regions switch to en-001 on the client. en-GB, en-AU and en-IN pass the sample and keep their tag, although their full dates differ in punctuation. The front-desk browser suite checks en-IT and en-GB in that WebKit.
  • Safari on macOS, iOS and iPadOS uses Apple’s ICU, which differs by OS version and was not measured. Because the check reads the browser’s own data, Safari keeps en-GB, en-IT and every other region wherever its data have them, and falls back where they do not.

The check compares one short date and one number. A browser that agrees on them but writes another style differently, such as a full date’s punctuation, keeps the tag; nothing that it formats was rendered by the server, so hydration still matches.

Money in the staff interface uses the locale’s ordinary number separators, for both display and entry, while retaining CLDR currency placement, names, signs and exact significant decimals. CLDR’s number specification permits distinct currency separators. The CLDR 48 en_IT data overrides ordinary decimal/group symbols; the observed inherited currency symbols still produce €1,234.50 natively for these English European locales. The staff interface therefore writes money with formatStaffMoney of @guestcraft/ui, which MoneyInput also uses: it passes separators: "number" to formatMoney, which substitutes ordinary numeric separators in currency format parts. Biome refuses a direct formatMoney import in the web app. Without that option formatMoney keeps CLDR’s currency separators, which differ deliberately in some locales (de-AT: € 1.234,50, whose numbers read 1 234,5; fr-CH; pt-CV), and which guest documents keep. en-IT shows €1.234,50, and its code-labelled input shows and accepts 1.234,50 as 1234.50. en-US shows €1,234.50 and accepts 1,234.50. The currency never comes from the region. For KWD, 1.234 explicitly means 1234.000 in en-IT and 1.234 in en-US; no alternate convention is guessed. Mixed separators, and ambiguous alternate points such as 1.234 in French, are refused with a field-linked error. ISO currency precision continues to govern input, independently of CLDR display precision.