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 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:
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 },
);import 'package:nt_voyager/inside/i18n/translations.g.dart';
// One whole message from the catalogue; the plural rule lives in en.i18n.yaml.
String orderFilled(int qty, String symbol) =>
t.order.filled(n: qty, symbol: symbol);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:
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, notlogin.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 noButtonsuffix. - Don’t use a shared
common:orbuttons: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:
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 committedOn 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
- Unicode CLDR, locale data behind every formatting library above.
- ICU MessageFormat (FormatJS), the plural/select/number syntax to standardise on.
- slang, the Flutter localisation library.
- .NET globalization and localization,
IStringLocalizerand.resx.