Engineering

One shared call, five targets, three different strings

Locale-aware formatting is implemented per platform, and the outputs differ.

MobileByteSensei 11 min read
kmpi18narchitecture

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 shared formatCurrency(123450, "GBP") with Locale("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.

The same shared call producing three distinct strings across five targets with two of them correct for their platform, and the formatted string reaching the analytics warehouse as three unparseable representations of one amount

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.

Three separate requirements wearing one function name, where display wants divergence, a machine value wants no formatting at all, and a reproducible document 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.

Three findings: only one of four outputs was actually wrong, the warehouse could aggregate none of the rows, and a runtime upgrade changed a locale separator with no code change

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.


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".

Follow by RSS