One shared call, five targets, three different strings
Locale-aware formatting is implemented per platform, and the outputs differ.
TL;DR
"£1,234.50"on Android, desktop and the web,"£1 234,50"on iOS,"1234.5 GBP"on wasm — three distinct strings across five targets, from one sharedformatCurrency(123450, "GBP")withLocale("en-GB")everywhere.- Locale-aware formatting is implemented per platform. The CLDR data, its version, and the fallback behaviour all differ, and three of the four were correct for their own platform.
- The fix was not a shared formatter — it was deciding which outputs must match and which may not, then testing the ones that must against a committed golden set.
- What reading on buys: the four outputs measured, the three categories that need different treatment, and the test that caught a separator change in an OS update.
The case this post is about
A price, formatted once:
// commonMain/kotlin/format/Money.kt
expect fun formatCurrency(minorUnits: Long, currencyCode: String, locale: String): String
// androidMain / jvmMain
actual fun formatCurrency(minorUnits: Long, currencyCode: String, locale: String): String =
NumberFormat.getCurrencyInstance(Locale.forLanguageTag(locale)).apply {
currency = Currency.getInstance(currencyCode)
}.format(minorUnits / 100.0)
// iosMain
actual fun formatCurrency(minorUnits: Long, currencyCode: String, locale: String): String =
NSNumberFormatter().apply {
numberStyle = NSNumberFormatterCurrencyStyle
this.locale = NSLocale(locale)
this.currencyCode = currencyCode
}.stringFromNumber(NSNumber(minorUnits / 100.0))!!
// wasmJsMain
actual fun formatCurrency(minorUnits: Long, currencyCode: String, locale: String): String =
(minorUnits / 100.0).toString() + " " + currencyCode // ← no Intl binding at the time
That last line is a placeholder somebody wrote to make the module compile, and it shipped. Part of the fix below is simply giving wasm the formatter the browser already has:
// wasmJsMain — Intl is right there; it needed an external declaration, not an abstraction
@JsFun("""(minor, code, locale) =>
new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(minor / 100)""")
external fun intlCurrency(minor: Double, code: String, locale: String): String
actual fun formatCurrencyForDisplay(minorUnits: Long, currencyCode: String, locale: String) =
intlCurrency(minorUnits.toDouble(), currencyCode, locale)
Used in a price label, an order confirmation, a receipt PDF and an analytics event.
The symptom
A screenshot in a bug report, iOS, a UK user:
Total: £1 234,50
A space as the thousands separator and a comma as the decimal — which is a French convention, on a
device set to en-GB. Not a bug in our code, and not a bug in iOS: NSLocale("en-GB") is not the
same thing as NSLocale(localeIdentifier: "en_GB"), and the underscore form is what the API wants.
Given an identifier it does not recognise, it fell back to the system's region formatting in a way
that produced a mixed result.
The full comparison, same inputs, after we went looking:
formatCurrency(123450, "GBP", "en-GB")
android £1,234.50
desktop £1,234.50
web £1,234.50
ios £1 234,50 ← the bug
wasmJs 1234.5 GBP ← no formatter at all
And the one that mattered more, found in the same sweep:
formatCurrency(123450, "GBP", "en-GB"), on the ANALYTICS event
android "£1,234.50"
ios "£1 234,50"
→ cart_value in the warehouse: a STRING, three shapes, unparseable
The analytics event was sending a formatted string, so the warehouse held three different representations of the same amount and no way to sum them.

Why the obvious fix failed
The first fix was the underscore, which fixes iOS and nothing else:
this.locale = NSLocale(locale.replace('-', '_'))
iOS now matches Android. The web target still returns "1234.5 GBP", and the analytics event still
carries a formatted string.
The second fix was to write one formatter in shared code so all five agree:
// commonMain — a hand-rolled formatter
fun formatCurrency(minor: Long, code: String): String {
val symbol = SYMBOLS[code] ?: code
val whole = minor / 100; val frac = minor % 100
return "$symbol${whole.groupedBy(',')}.${frac.toString().padStart(2, '0')}"
}
Which is correct for en-GB and wrong for everyone else, and the wrongness is not cosmetic: a
German user sees £1,234.50 where their platform would correctly show 1.234,50 £, and a Japanese
user sees two decimal places on a currency that has none. Reimplementing CLDR is a multi-year
project and we had SYMBOLS with eleven entries.
The third attempt was to pick one platform's output as canonical and make the others match it, which is the same mistake with extra steps — it makes four platforms wrong for their users in order to agree with each other.
What resolved it was noticing there were three different requirements wearing one function name.
The actual mechanism
Locale-aware formatting is a platform service, and the platforms differ on three axes.
The implementation differs. java.text.NumberFormat on the JVM family, NSNumberFormatter on
Apple platforms, Intl.NumberFormat in a browser, and nothing in Kotlin/Native's standard library
or in wasm without a JS binding.
The CLDR data version differs, because it ships with the platform. An Android 12 device and an Android 14 device can format the same locale differently, and Unicode does change separators between CLDR releases — which is the thing that broke our golden test later and was the most useful finding.
And the fallback behaviour differs. Given an unrecognised locale identifier, the JVM falls back
to the default locale, NSLocale falls back to a region-derived format, and Intl throws a
RangeError. Three behaviours for the same bad input, and ours was a hyphen.
Which means the three requirements are genuinely different:
1. DISPLAY to a user must be correct FOR THAT USER on THAT platform
→ use the platform formatter; outputs SHOULD differ
2. a STABLE machine value must be identical everywhere
→ never format it; send the minor units and the code
3. a reproducible DOCUMENT must be identical across platforms AND over time
(an invoice, a receipt) → a pinned formatter, version-controlled, not the platform's
The original function was being used for all three, so there was no answer that could be right. Category 1 wants divergence, category 2 wants no formatting at all, and category 3 wants determinism the platform cannot promise across OS versions.

The fix
Three functions with three contracts, and a golden test only on the one that must be stable.
// commonMain/kotlin/format/Money.kt
/**
* 1. DISPLAY. Delegates to the platform, and the outputs are EXPECTED to differ between
* platforms and between OS versions — that is the platform being right for its user.
* Deliberately NOT golden-tested: a test pinning this output would be asserting that one
* platform's CLDR version is correct, which is not a property we want.
*/
expect fun formatCurrencyForDisplay(minorUnits: Long, currencyCode: String, locale: String): String
/**
* 2. MACHINE. There is no formatting here, and that is the whole point. An analytics event,
* an API payload and a log line carry the minor units and the code, and whatever consumes
* them formats at the edge if it needs to.
*/
@Serializable
data class Money(val minorUnits: Long, val currencyCode: String) {
// A deliberate absence: no toString() override. The original defect was a formatted
// string reaching the warehouse, and a convenient toString() is how that happens again.
}
/**
* 3. DOCUMENT. A pinned implementation, in shared code, for output that must be byte-identical
* across platforms and stable across years — an invoice reprinted in 2029 must match the
* one sent in 2026. The platform formatter cannot promise that, because its CLDR data ships
* with the OS.
*
* Supports the eleven currencies we invoice in and throws on anything else, which is correct:
* an unsupported currency on an invoice must be a failure, not a guess.
*/
object InvoiceFormat {
private val SPEC = mapOf(
"GBP" to Spec("£", groupSep = ",", decSep = ".", decimals = 2, symbolFirst = true),
"EUR" to Spec("€", groupSep = ".", decSep = ",", decimals = 2, symbolFirst = false),
"JPY" to Spec("¥", groupSep = ",", decSep = "", decimals = 0, symbolFirst = true),
// … eight more, each one a deliberate decision recorded with the finance team
)
fun format(money: Money): String {
val s = SPEC[money.currencyCode]
?: error("no invoice format for ${money.currencyCode} — add it to SPEC with " +
"finance sign-off rather than falling back to a platform formatter, " +
"which would make this invoice unreproducible")
val units = money.minorUnits / POW10[s.decimals]
val frac = money.minorUnits % POW10[s.decimals]
val grouped = units.toString().reversed().chunked(3).joinToString(s.groupSep).reversed()
val number = if (s.decimals == 0) grouped
else "$grouped${s.decSep}${frac.toString().padStart(s.decimals, '0')}"
return if (s.symbolFirst) "${s.symbol}$number" else "$number ${s.symbol}"
}
}
// commonTest/kotlin/format/InvoiceFormatTest.kt
//
// A GOLDEN test, on category 3 only. The file is committed and a change to it is a deliberate
// act with a reviewer — which is the property an invoice needs and the platform cannot give.
class InvoiceFormatTest {
@Test
fun invoiceFormattingIsIdenticalOnEveryTargetAndStableOverTime() {
val golden = readResource("invoice-format-golden.txt") // committed
val actual = CASES.joinToString("\n") { (money, _) ->
"${money.currencyCode} ${money.minorUnits} → ${InvoiceFormat.format(money)}"
}
assertEquals(golden.trim(), actual.trim(),
"invoice formatting changed. If this is intentional, update the golden file IN " +
"THE SAME COMMIT and note the finance sign-off — every historical invoice is " +
"expected to reproduce byte-identically from this code.")
}
@Test
fun anUnsupportedCurrencyFails() {
// Not a fallback. A guess on an invoice is worse than a refusal.
assertFailsWith<IllegalStateException> { InvoiceFormat.format(Money(1000, "XYZ")) }
}
}
And the display formatter's test, which asserts a property rather than a string:
@Test
fun displayFormattingIsPlausibleOnEveryTarget() {
val out = formatCurrencyForDisplay(123450, "GBP", "en-GB")
// NOT an equality assertion. The output legitimately differs per platform and per OS
// version; what must hold is that it contains the amount and the currency's symbol and
// has not fallen back to a bare number.
assertTrue("1" in out && "234" in out, "the amount is missing from '$out'")
assertTrue("£" in out, "the currency symbol is missing from '$out' — a locale fallback")
assertFalse("GBP" in out, "'$out' shows the code rather than the symbol: no formatter ran")
}
And the output it produced:
$ ./gradlew :core:format:allTests
invoiceFormattingIsIdenticalOnEveryTargetAndStableOverTime PASSED × 5 targets
displayFormattingIsPlausibleOnEveryTarget PASSED × 5 targets
anUnsupportedCurrencyFails PASSED × 5 targets
$ ./tools/display-format-probe.sh
formatCurrencyForDisplay(123450, "GBP", "en-GB")
android £1,234.50 ios £1,234.50 desktop £1,234.50 web £1,234.50 wasm £1,234.50
formatCurrencyForDisplay(123450, "GBP", "de-DE")
android 1.234,50 £ ios 1.234,50 £ desktop 1.234,50 £ web 1.234,50 £ wasm 1.234,50 £
↑ correctly DIFFERENT from the en-GB row
How we proved it
That the platform outputs were mostly correct — which is what ruled out a shared formatter:
locale platform output CLDR-correct for that locale?
en-GB £1,234.50 yes
en-GB £1 234,50 no — a locale-identifier fallback, our bug
de-DE 1.234,50 £ yes
ja-JP ¥123,450 yes — no decimals, correctly
Only one of the four was wrong, and a hand-rolled shared formatter would have made three of them wrong.
That the analytics string was the expensive half. Counted in the warehouse, which is where the cost landed:
distinct cart_value representations, 30 days
"£1,234.50" 412,008 android, desktop
"£1 234,50" 288,114 ios
"1234.5 GBP" 98,220 web
rows where cart_value could be summed: 0
Nothing could be aggregated. The display bug affected one platform; the formatted-string-as-data affected every revenue query.
That an OS update changed a separator. This is the finding that justified the golden test, and we got it by accident — the test failed on a CI runner upgrade with no code change:
$ ./gradlew :core:format:jvmTest # after the runner's JDK moved a CLDR version
displayFormattingIsPlausibleOnEveryTarget PASSED
# but an earlier, equality-based version of the same test, on fr-FR:
expected: "1 234,50 €" # U+00A0 NO-BREAK SPACE
actual: "1 234,50 €" # U+202F NARROW NO-BREAK SPACE ← CLDR 42
Those two strings are identical on screen and differ by one code point. A property assertion survived it; an equality assertion failed with a diff nobody could read. That is the reason category 1 is not golden-tested and category 3 does not use the platform — and it is also why the invoice golden file is checked as bytes rather than eyeballed.
That the NSLocale fallback was the actual bug. Isolated, because "iOS formats differently" has
several possible causes:
NSLocale("en-GB") → £1 234,50 ← unrecognised identifier
NSLocale("en_GB") → £1,234.50
NSLocale.currentLocale → £1,234.50 (on a UK device)
One character. And the failure mode is the dangerous kind: a plausible, formatted, wrong result rather than an error.
That a missing toString() is load-bearing. The fixture, because adding one is the obvious
convenience:
$ ./gradlew :core:format:allTests --tests '*MoneyIsNotFormattedByAccident*'
with a toString() override: "cart_value" to money → "£1,234.50" in the payload FAILED
without: "cart_value" to money → {"minorUnits":123450,…} PASSED
A toString() on Money recreates the original defect the first time somebody interpolates it
into a log line or a map value.
What I cannot tell you is whether the eleven invoice specs are right for every edge case within
those currencies. They were reviewed with finance for the amounts and currencies we actually
invoice; negative amounts, and currencies with non-Western digit grouping like en-IN's lakh
grouping, are not in the set and would need the same review rather than a pattern I invented.
And the honest scope of the golden test. It pins InvoiceFormat, which is our code, so it is
genuinely deterministic. It does not and cannot pin what a user sees — so an invoice PDF is
reproducible and a price label is not, which is correct and means "the app showed £1,234.50 on that
date" is not a claim we can support.

What this generalizes to
A function that produces a string for a human and a function that produces a value for a machine are different functions even when the arithmetic is identical, and merging them puts a presentation concern into a data path where it cannot be aggregated. For the human-facing half, the platform is usually right and divergence is the correct outcome — so assert properties rather than equality. For anything that must reproduce across platforms or across years, the platform's locale data is a moving dependency and the only stable answer is an implementation you own and version.
Related
- The same
dpis a different number of pixels on every target — the layout version of the same platform-supplied-value problem. kotlinx.datetimeships per platform — the other locale-adjacent service, where the time zone database has the same versioning issue.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".