Skip to content

Internationalisation

Every user-facing surface ships translatable.

Updated View as MarkdownView on Github

Every user-facing surface supports translation. Adding language support later costs much more than building it in from the start. Our whitelabel and partner deployments also mean that a surface with hardcoded English will not last long.

Principles

  • User-facing strings come from message catalogues, never literals in components. Extraction runs in CI. A string that cannot be extracted is a defect, not a follow-up.
  • Catalogues are the source and are reviewed like code. Translations use the same pull request flow; nobody edits copy in production.
  • Format locale-dependent values through the shared formatting layer: numbers, dates, times, currency, and instrument prices. A hand-rolled toFixed or date string works correctly in exactly one locale, and the decimal separator can cause real trading errors.
  • Never build a sentence from translated fragments. Interpolate into one complete message, so the translator chooses the word order.
  • Layout must handle longer text. Assume translated text can be much longer than English, and never put copy into an image.
  • Regulatory and disclosure copy is controlled content, not free translation. When a jurisdiction requires specific wording, it changes with Compliance, not with a catalogue update.

Message format

Use ICU MessageFormat as the message syntax across surfaces. Plurals, gender/select, and number and date skeletons then stay in one message string. The translator decides them, not the developer. ICU works across every stack we run, so a term extracted on one surface looks the same on another.

Each surface keeps its native catalogue file and tooling:

Surface Tooling Catalogue
Flutter (Dart) slang .i18n.yaml
TypeScript / Node FormatJS or i18next, extraction via @formatjs/cli or i18next-cli .json
.NET (C#) IStringLocalizer .resx
Scala services ICU4J for any user-facing text catalogue per service

An .arb catalogue stores the message and its placeholder metadata as the source:

{
  "openPositions": "{count, plural, =0{No open positions} one{{count} open position} other{{count} open positions}}",
  "@openPositions": { "placeholders": { "count": { "type": "int" } } },
  "fillPrice": "Filled at {price}",
  "@fillPrice": { "placeholders": { "price": { "type": "double", "format": "decimalPattern" } } }
}

Interpolate into one message so the word order can be translated. Never join fragments:

Flutter

Slang supports ICU-style localisation concepts such as pluralisation and interpolation, but the mobile app’s YAML translation files use slang’s syntax rather than ICU MessageFormat syntax. The .arb catalogue above looks like this in slang:

en.i18n.yamlyaml
openPositions(plural):
  zero: "No open positions"
  one: "{n} open position"
  other: "{n} open positions"
fillPrice: "Filled at {price}"

Use slang’s built-in modifiers instead of custom patterns:

  • {param} for interpolation.
  • (plural) for plurals. Slang picks the category (zero, one, two, few, many, other) from CLDR plural rules, so a translator still supplies every form their language needs.
  • (context=<Enum>) for select, such as gender or order side.
  • (rich) for inline spans such as links or bold text, so the sentence stays one message.

Name keys by role, in the form feature → screen → role:

  • Name a key for its purpose, not its copy or widget. Use login.submit, not login.loginButton.
  • Keep keys at most four levels deep. Use the fourth level only for a dialog.
  • Nest only for a separate route or dialog. A section of a screen stays flat on that screen.
  • Give every screen a role leaf (accountSummary.title), never a bare value on the screen key.
  • Name buttons with verbs (accept, proceed, unlink) and no Button suffix.
  • Don’t use a shared common: or buttons: bucket. Each screen owns its strings.

This setup gives the app the following benefits:

  • Compile-time safety. Slang generates a typed accessor for every key. A missing key or a wrong parameter fails the build instead of showing a raw key at runtime.
  • One source catalogue. The YAML file is the only place user-facing copy lives. Widgets never hold string literals.
  • Stricter CI. CI fails when generated translations are out of date, when a key is unused, and when a key is missing from any locale.
  • Clear ownership. Keys are named by feature, screen, and role, and each screen owns its strings. Nothing sits in a shared bucket.

Locale-aware formatting

Use a CLDR-backed library for every locale-sensitive value instead of creating your own locale rules: Intl.NumberFormat and Intl.DateTimeFormat in JS/TS, intl NumberFormat in Dart, System.Globalization in .NET, and ICU4J on the JVM. All use Unicode CLDR, so grouping and the decimal separator are correct in every market. See the MDN Intl reference for the JS/TS surface.

Instrument prices have another rule: precision belongs to the contract, not the locale. Combine the contract’s tick precision with locale grouping so a price is correct and easy to read:

Time and trading sessions

Treat timezone display as an i18n concern. Store and send UTC, or exchange time with an explicit zone. Render it in the user’s zone with an IANA tz identifier. Never use server-local time. Fills, order timestamps, and session boundaries cross time zones, and a wrong offset reports the wrong trade time.

Locale negotiation and encoding

Use one negotiation order: honour an explicit user preference, then fall back through Accept-Language or device locale to a default. Use BCP 47 language tags, and require a base locale (en-US) that always resolves. Keep encoding consistent from end-to-end. Use UTF-8 everywhere and Unicode NFC on input, so names, symbols, and search work consistently.

State right-to-left support even before an RTL locale ships. Mirror the layout with logical start/end properties, isolate interpolated values with Unicode bidi controls, and test at least one RTL locale.

CI enforcement

Extraction is a quality gate, not a suggestion. The build runs extraction again and fails when the committed catalogue is out of date:

- name: i18n extract check
  run: |
    npx i18next-cli extract
    git diff --exit-code src/locales/en/*.json   # extraction must be committed

On Flutter, CI regenerates the slang output and fails when it differs from what is committed.

Add pseudolocalisation to CI. This accented, expanded pseudo-locale exposes hardcoded strings, truncation, and concatenation before real translation exists.

Catalogues reviewed “like code” still need linguistic and compliance review. Give translators screenshots and glossary context (a documentation concern), and set an SLA for a new string to be translated before release.

References

Navigation

Type to search…

↑↓ navigate↵ selectEsc close