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
toFixedor 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:
export function formatPrice(price: number, locale: string, tickDecimals: number) {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: tickDecimals,
maximumFractionDigits: tickDecimals,
}).format(price);
}
// tickDecimals comes from the contract spec; grouping and decimal separator come from CLDR.import 'package:intl/intl.dart';
String formatPrice(num price, String locale, int tickDecimals) =>
NumberFormat.decimalPatternDigits(locale: locale, decimalDigits: tickDecimals)
.format(price);
// tickDecimals comes from the contract spec; grouping and decimal separator come from CLDR.using System.Globalization;
static string FormatPrice(decimal price, string locale, int tickDecimals) =>
price.ToString("N" + tickDecimals, CultureInfo.GetCultureInfo(locale));
// tickDecimals comes from the contract spec; grouping and decimal separator come from CLDR.import com.ibm.icu.text.NumberFormat
import com.ibm.icu.util.ULocale
def formatPrice(price: Double, locale: String, tickDecimals: Int): String = {
val nf = NumberFormat.getNumberInstance(new ULocale(locale))
nf.setMinimumFractionDigits(tickDecimals)
nf.setMaximumFractionDigits(tickDecimals)
nf.format(price)
}
// tickDecimals comes from the contract spec; grouping and decimal separator come from CLDR.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 committedAdd 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
- Unicode CLDR, locale data behind every formatting library above.
- ICU MessageFormat (FormatJS), the plural/select/number syntax to standardise on.
- Flutter internationalization,
.arb+gen-l10nworkflow. - .NET globalization and localization,
IStringLocalizerand.resx.