Skip to content

Internationalisation

Every user-facing surface ships translatable.

Updated View as Markdown

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 one 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, and all use ICU:

Surface Tooling Catalogue
Flutter (Dart) flutter_localizations + intl, gen-l10n .arb
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:

import { IntlShape } from '@formatjs/intl';

// One whole message with an extractable id; the plural rule lives in the catalogue.
export const orderFilled = (intl: IntlShape, qty: number, symbol: string) =>
  intl.formatMessage(
    { id: 'order.filled', defaultMessage: '{qty, plural, one{# contract} other{# contracts}} of {symbol} filled' },
    { qty, symbol },
  );

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

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