◆ Flux

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 app block (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.

FLUX
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 all

The 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 dayOfWeek for 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.

FLUX
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:

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.

FLUX
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 a string. It does not change x, 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).

FLUX
byName = vec.sortBy(rows, (r) -> r.name)   // ✗ [ErrArg] — a `string` is not an ordered key kind
"a" < "b"                                  // ✗ [ErrDim] — no ordering on `string`, ever

So collation ships as its own pinned combinator rather than as a key-function trick:

FLUX
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` value

The 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 and sortBy on 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 a string is 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.

FLUX
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:

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

FLUX
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