i18n — locales, messages, collation
The i18n pillar is a sealed, additive design whose rollout follows the runtime it extends. It opens the message-catalogue seam the APP plane holds reserved, and adds no sort, no grammar symbol and no new arrow to the language.
An application that speaks one language is a prototype. Making it speak many is usually where a codebase acquires its most durable class of bug: a number that reads differently depending on who is looking at it, a plural that is correct in the language the developer happened to think in, a sort order that changes when a translation ships, a right-to-left name that reorders the punctuation around it.
Flux takes all of that seriously and refuses exactly one thing: it will not let any of it reach a computed value. A locale decides how a number is rendered and how two names are ordered. It never decides what a number is. Everything on this page follows from that sentence.
New here? Start with Guide §10 — Building an application → — the capabilities, subscriptions and journaled messages this pillar rides on, in plain terms.
Reading the examples. Two conventions are in use below, and both are sanctioned. A line marked ✗ is often a bare expression fragment — Flux has no expression-statements, so it illustrates a kind rule rather than a program. And a member of an
appblock (update,view,subs) is sometimes shown on its own, since a member is only legal inside its block. Everything else is a complete statement that parses as written.
A locale is a value
locale is an opaque string key — "fr", "en-GB", "ar" — delivered by the host as an
explicit input: an APP-plane input, or a pinned entry in the replay context. It is never an
ambient per-visitor default that a computation can reach for.
loc = input("en", title: "Locale") // explicit, and pinned into the replay input set
def caption() = fmt.duration(4800000, loc) // a RENDERING — "1 hr 20 min" · "1 h 20 min"
plot ema(close, 20) // the SERIES cannot depend on a locale at allThe number in that second line is the same number in every locale. Only the string changes.
Why the locale is never ambient. This is the time-zone rule, verbatim. A calendar accessor lives in the ANALYSIS plane, so if the chart’s zone were an ambient per-visitor setting, the author, the compiled module and the server re-executing the script would each compute a different
dayOfWeekfor the same bar — and a script’s output would depend on who opened it. Pinning the tables closes the drift; it does not close the question of which zone the default resolves to. So the zone is either an explicitly pinned input or an explicit argument. A locale is the same kind of hazard with a wider blast radius, and it gets the same answer: an explicit value, in the replay input set, or nothing.
Locale negotiation — a visitor’s preferences against the locales an app declares — is host chrome. The application never runs that algorithm; it receives the resolved value. And for anything scored, replayed or verified, the resolved locale is frozen into the replay inputs alongside the seed and the table versions.
The tables are pinned
Every locale-dependent behaviour reads a pinned, versioned CLDR subset. Not the platform’s internationalization library — a table with a version number that ships inside the build.
| Table | Decides | Read by |
|---|---|---|
| Plural rules | which variant of a message a count selects | t, fmt.relTime, fmt.duration |
| Number symbols | decimal separator, group separator, digit shaping | fmt.num, fmt.pct, fmt.price |
| Date/time patterns | field order, month and weekday names | fmt.time |
| List patterns | “a, b and c” against “a, b et c” | fmt.list |
| Collation tailorings | the order of two strings in a locale | coll.sort, coll.topK, coll.fold |
| Script and RTL metadata | the base direction of a locale | textDir, the layout |
| Bidi (UAX #9) | how mixed-direction runs are reordered | the host renderer |
A table bump is a new pinned version, which is a new build hash. It is never a silent drift underneath a frozen application — the failure mode where a routine library update quietly changes what an app renders, or how it sorts, has no way to happen here.
Delivery is split, once. The en subset is embedded in the runtime; every other locale’s
subset is a lazy, versioned bundle asset, loaded per declared locale. This is deliberately
unlike the time-zone database, which is embedded whole: only en has that platform status, and
the next section says exactly why, and exactly how far that privilege goes.
The message catalogue
Without this pillar, application strings live in the source; the message catalogue lifts that limit with a seam that keeps the strings out of the script entirely.
- The application declares keys. The host holds the catalogue: a table
(appId, locale) → { key → message }, authored alongside the app and delivered through the asset-bundle mechanism. Catalogues therefore version with the app, cache offline, and never enter the script as bytes. - The capability
i18n:cataloguegrants two things:t(key: string, args: record) -> string, resolved host-side (selection and formatting), andSub OnLocale(C). It is an ordinary entry in the capability catalogue, declared in the manifest and inspectable before installation like every other unlock — see host services.
app reader {
capabilities: [ i18n:catalogue ]
init(p) = { locale: p.locale, unread: 3 }
update(m, msg) = match msg {
Locale(l) -> { model: m with { locale: l }, cmds: [] }
}
view(m) = col { text(t("inbox.unread", { count: m.unread })) }
subs(m) = [ OnLocale(Locale) ]
}The script names a key and hands over arguments. It never sees a message, never concatenates one, and never parses one.
Why the catalogue is host-held. A script that carried its own strings would have to do something with them — select a plural form, interpolate an argument, pick a gendered variant — and that means parsing a message format at runtime, which A12 forbids for the same reason it forbids every other runtime grammar. Handing the catalogue to the host moves the parse to load time, through a fixed, pinned grammar, once. What the script holds is a key: a value with no structure to interpret.
Both failure modes are total. Neither throws, and neither returns an empty string:
| Failure | Behaviour |
|---|---|
| Missing key | the declared fallback chain (fr-CA → fr → en), then the key itself, verbatim, plus a diagnostic |
Placeholder with no matching args field, or a wrong-kind value |
the literal placeholder token is rendered, plus a diagnostic |
Extra args fields |
ignored |
Where the catalogue is available at build time — a first-party app, its own strings — the
placeholder set is compile-checked against every t(key, args) call site, and an unknown
placeholder is [ErrInput] before the app ever runs. Third-party bundles that load lazily fall
back to the runtime rule above. A translator’s typo degrades one label; it does not take down a
view.
A message is a selection tree, not a concatenation
Messages use MessageFormat 2: declarations, .match selectors for plural, ordinal and general
selection — gender lives here — and placeholders with formatting functions. A message is written by
a translator and looks like this, in the catalogue, never in your source:
.input {$count :number}
.match $count
one {{You have {$count} new message.}}
* {{You have {$count} new messages.}}Why selection cannot be done by gluing strings together. “You have {n} new messages” is not one message with a hole in it. In English it is two forms. In other languages it is three, four, or six, and which one applies is a function of the number that no application should be encoding. Gender is worse: it is not a prefix you can concatenate, because in many languages it changes agreement across the whole sentence. Concatenation forces every translator into the grammar of the language the code was written in, and produces text that is correct nowhere else. Putting the selection inside the message — in the catalogue, where the translator works — lets a message be restructured for its language without a single line of code changing.
Three properties keep this deterministic:
- Selection reads the pinned plural rules for the message’s locale. The categories are
zero one two few many other; which of them a locale actually uses is the table’s business, and the table has a version. - Formatting functions delegate to the pinned
fmt.*routines. There is one formatting source of truth in the whole system; MessageFormat never grows a second number or date formatter that could round differently from the first. - The message grammar is fixed — A12-conform, like every codec — and it is parsed host-side at catalogue load. A malformed message is a load-time diagnostic with a key-verbatim fallback, not a runtime surprise.
Locale-aware formatting
fmt.* gains an explicit locale. The locale-invariant forms remain, and they stay the default
in the ANALYSIS plane.
| What | Locale-invariant form | Locale-aware form |
|---|---|---|
| Number | fmt.num(x) |
fmt.num(x, locale) |
| Percentage | fmt.pct(x) |
fmt.pct(x, locale) |
| Price | fmt.price(x) |
fmt.price(x, locale) |
| Date and time | fmt.time(t, pattern, zone) |
fmt.time(t, pattern, zone, locale) |
| Relative time | — | fmt.relTime(t, ref, locale) |
| Duration | — | fmt.duration(d, locale) |
| List | — | fmt.list(v, listType, locale) |
The bottom three rows are net-new surface, and they have no locale-invariant form for a good reason: there is no locale-invariant answer to “three hours ago”. They are the humanization pair plus the list joiner, and they ride the plural rules like everything else — the unit choice (seconds → minutes → hours → days → weeks → months → years) is window-bounded, not open-ended.
def posted(ts, ref, loc) = fmt.relTime(ts, ref, loc) // "3 hours ago" · "il y a 3 heures"
def spanOf(d, loc) = fmt.duration(d, loc) // "1 hr 20 min" · "1 h 20 min"
def stamp(ts, loc) = fmt.time(ts, "d MMM y", "UTC", loc)Currency rendering composes the pinned symbol table with the quote tag an amount already
carries, so a price[BTC, EUR] renders with the right symbol in the right position for the locale
without anyone passing the currency twice. See asset & currency.
Every one of these is a pinned routine — interpreter ≡ compiled module ≡ server — with a golden
per routine and locale family. Because the locale is a parameter rather than a mode, two locales
can never race inside one oracle run: the golden for ("fr") and the golden for ("ja") are two
independent, reproducible facts.
Why the number never becomes locale-dependent.
fmt.num(x, loc)returns astring. It does not changex, and there is no “current locale” that arithmetic could consult. This is the whole trick, and it is worth being blunt about what it rules out: an application cannot branch on a formatted number, cannot compute with one, and cannot feed one back into analysis, because it is text — and text is not plottable, not ordered, and not numeric. The locale reaches the rendering and stops there.
Collation — an order without an operator
The frozen ordering machinery does not admit string keys. sortBy’s key function must return
an ordered scalar, and string is excluded from ordering (there is no < on strings, by A12).
byName = vec.sortBy(rows, (r) -> r.name) // ✗ [ErrArg] — a `string` is not an ordered key kind
"a" < "b" // ✗ [ErrDim] — no ordering on `string`, everSo collation ships as its own pinned combinator rather than as a key-function trick:
rows = Vec.of([{ name: "Ötzi" }, { name: "Adam" }, { name: "Zoë" }]) // Vec(record{ name: string }, 3)
loc = "de"
needle = "STRASSE"
ranked = coll.sort(rows, (r) -> r.name, loc) // the pinned CLDR order for an explicit locale
top10 = coll.topK(rows, 10, (r) -> r.name, loc) // the same order, bounded result
folded = coll.fold(needle, loc) // case-insensitive matching → a `string` valueThe extracted string is data. The order applied to it is the pinned CLDR collation order
for the explicit locale, with tailorings versioned per locale, and with the same absent-last,
stable-by-index policy that sortBy and topK already use. The routine joins the pinned set and
carries its golden.
Why this is not a loophole. General string ordering stays inexpressible:
<on strings andsortByon a string key remain errors after this pillar ships. What exists is one named, locale-explicit, pinned order — precisely the shape the calendar already has, where arbitrary time-zone arithmetic does not exist but the named, pinned IANA tables do. The A12 exclusion of locale-dependent collation rested on two grounds, and both are answered rather than waived: the determinism ground is dissolved by pinning (the locale is explicit, the tables are versioned), and the totality ground already held, because astringis bounded — bounded input, bounded key, total order.
The third of those is case folding, and it answers a different question from the other two: not
what order, but do these match. coll.fold(s, locale) reads the same tables and returns a
string. Folding produces data, and equality on strings is already admitted as bit-equality —
so a folded comparison needs no new operator either, and matching “STRASSE” against “straße” stops
being a special case somebody has to remember.
The key-derivation cap for very long strings — the bounded-input, bounded-key policy — is a numeric bound the plan sizes when the combinator is pinned, within the general boundedness the rest of the pillar guarantees.
Right-to-left and bidi
textDir(locale) -> dir returns the existing dir kind: 1 for left-to-right, -1 for
right-to-left. It is consumed as data — the host flips rail and panel sides from tokens, and an
application reads the value only for content decisions.
def isRtl(loc) = textDir(loc) == -1 // `dir` is discriminated by comparison, never by `match`This is deliberately not an ANALYSIS presentation of dir — whose only chart channels remain
marks and bar colouring — so the pillar adds zero new kinds.
Three things then follow, and all three are host-side:
- Text runs render with UAX #9 bidi isolation. The table is owned and pinned by this pillar, version-locked alongside the segmentation tables in text.
- Interpolated values are isolate-wrapped by default. A user-supplied right-to-left string dropped into a left-to-right sentence otherwise reorders the punctuation around it — the trailing period jumps to the front of the line, the parentheses swap. It is one of the most reliably shipped bugs in the industry, and defaulting to isolation means an application cannot ship it.
uicontainers carry a logical-direction contract — start and end, rather than left and right — resolved by the host. There is no per-app mirroring code, and therefore no per-app mirroring bug.
en is the base locale; nothing else is privileged
en is embedded in the runtime as the fallback terminal — the only locale with platform status,
and it has that status for exactly one reason: a fallback chain needs somewhere to stop.
Every other locale is a uniform citizen. fr, es, it, de, ja, ar — all of them are
delivered the same way: versioned bundle assets, lazy per declared locale, with no special-casing
anywhere in the machinery. An application declares its locale list in the manifest, where it is
inspectable before installation, exactly like a capability.
That uniformity is a design commitment, not a coincidence of the current bundle. The first-party product happens to ship French first because its audience is French — through the identical mechanism any locale uses, with no shortcut available to it that a third-party app could not take. A platform that grew a privileged second locale would grow a second code path with it, and the second code path is where the divergence lives.
The exact cut of which tables the embedded en base carries, against what even en may lazy-load,
is a packaging trade-off the plan sizes at build time — the collation root is the heavy candidate —
not a semantic choice, since either way the tables are pinned and versioned.
Reacting to a locale switch
subs(m) = [ OnLocale(Locale) ]Sub OnLocale(C) delivers a locale change as a journaled message, carrying the constructor that
routes it into update. The application re-renders. Nothing else changes.
OnLocale is an additive opening of the closed subscription catalogue — the same extension
mechanism the network subscriptions use — and not a special case bolted onto the side of it.
Why a message rather than an ambient re-read. The journal is the single source of truth: an application’s entire behaviour is reconstructible by folding its messages. A locale switch that mutated an ambient global would be an input that never entered the journal, and the same session would then re-fold to a different view. As a message, it is recorded, replayable, and testable like every other edge — and the fact that the view changes while the model’s numbers do not is visible right there in the fold.
Determinism, in three rules
Everything above collapses into three sentences, and they are the reason the pillar looks the way it does rather than the way an internationalization library usually looks.
1. A locale affects rendering and ordering. Never a computed value.
| A locale MAY decide | A locale may NEVER decide |
|---|---|
how a number is rendered (fmt.num(x, loc) → a string) |
the value of x |
the order two names appear in (coll.sort(…, loc)) |
the result of any numeric comparison |
| which plural form a message takes | which branch an if takes in ANALYSIS |
| the base direction of the layout | a plotted series, or a signal |
2. Every table is pinned and versioned, so two engines agree — and so a table update is a build change you can see, rather than a behaviour change you cannot.
3. The locale is an explicit value, so it lives in the replay input set. A re-execution reproduces the same rendering, and a server verifying a result is looking at the same text the user saw.
What this costs, and what it does not
It costs a capability row, a pinned table family, one pinned combinator for ordering, and one
subscription. It adds no sort to the lattice, no symbol to the grammar, and no second arrow:
locale is a string value, t / coll.* / fmt.* are pinned routines behind prelude
definitions, and textDir reuses the dir kind that already exists.
The firewall is untouched. Locale-dependent output is presentation and APP-plane work; ANALYSIS
keeps the locale-invariant fmt.* defaults, and a locale reaches it only as an explicitly pinned
input.
Catalogue authoring tooling — the editor integration and the translation export format — follows the runtime, as tooling does.
Whether a locale-aware slug() pulls transliteration tables into the core, or keeps them out of it,
is one boundary the plan names without fixing, because the answer turns on how heavy the
transliteration tables prove in practice.
See also
- text — the
stringkind, segmentation, and the pinned formatter this pillar extends. - App plane — subscriptions, capabilities, and the journal
OnLocalerides. - collections — the ordered
Map, and the absent-last stable orderingcoll.sortreuses. - display — the
uicatalogue, logical direction, and the output membrane. - host services — the capability catalogue and resource-handle doctrine
i18n:cataloguejoins. - Kinds — why
stringhas no ordering, and whatdiris.