Presenting kotlinx-locale
Building the data layer, one of the challenges was sharing values already formatted and correctly localized across the different platforms. The Android app, the iOS app and the web portal read from the same source, and for the same value they should render the same string.
Delegating that formatting to each platform is where the inconsistency came from. Hand the same date in the same locale to three platforms and you can get three different strings back. That inconsistency is one of the problems the data layer was built to remove, so leaving locale formatting to the platforms worked against its own purpose.
What ICU and CLDR are
Two names run through the rest of this post. CLDR is the data and ICU is the code.
CLDR, the Unicode Common Locale Data Repository, records for every language how to format a date, group digits, name a currency and order a person’s name. Unicode maintains it and cuts a numbered release a couple of times a year.
ICU, International Components for Unicode, is what reads that data. It
takes CLDR plus a locale and turns a value into a string. Most platform locale APIs
are ICU, or something shaped like it, running on a bundled copy of CLDR. When
Android formats a date, when Apple’s frameworks do, when a browser’s Intl runs,
they are almost always reading CLDR through ICU underneath.
You can see the split in one date. Take the long format. CLDR stores the pattern each locale uses and the words that fill it:
pt-BR d 'de' MMMM 'de' y month name "julho"
en-US MMMM d, y month name "July"
MMMM is the wide month, d the day, y the year, and the text in quotes is
literal. ICU takes a date and a locale, looks up that locale’s pattern and names,
and fills it in. Give it 27 July 2026:
pt-BR 27 de julho de 2026
en-US July 27, 2026
The instruction to ICU is identical both times. Only the data it read changed.
That is why the two versions matter. A formatted result is a function of both the code and the data behind it, and every platform carries its own copy of each.
Why the platforms disagree
Kotlin has no multiplatform locale API. There is no common Locale type, and
kotlinx-datetime ships no locale data on purpose, so common
code cannot turn a date into “27 de julho de 2026” by itself.
The usual answer is expect/actual. Declare the formatting in common code and
let each platform implement it with its own API. We tried that first. It does not
hold, because the platforms do not model formatting the same way.
Android and the JVM format through DateTimeFormatter. iOS through
NSDateFormatter. The web through Intl.DateTimeFormat, which follows a
different model than either. The capabilities do not line up. Take skeleton
formatting. You name the fields you want, the year, an abbreviated month, the day,
and the locale decides their order and punctuation. Android and iOS do it. The web
has no built-in equivalent. So expect/actual does not paper over the gap. It
hands it to you, three capability sets to reconcile by hand.
The version problem
Say you did all of that. Matched the APIs by hand and wrote the missing skeleton support yourself. One problem survives it.
Every platform ships its own copy of the ICU/CLDR data, and the version it ships
is tied to the operating system. Two users on the exact same build of your app can
see different strings, because their phones carry different CLDR versions. Each one
is correct. They still disagree, and none of it is visible from the call site. So
expect/actual cannot deliver consistency even in principle. The data under it
is not the same data.
None of this is new. Twitter hit it on the web years ago and shipped twitter-cldr-rb, which carries its own CLDR data instead of trusting the host. The takeaway is the same for us. If you want the same result everywhere, own the data.
Ship the data, not a bridge to the host
So that is what we did. Instead of fighting each platform over what it can and cannot do, kotlinx-locale brings the data along. It compiles Unicode’s CLDR into Kotlin source, so the same call returns the same string on the JVM, Android, JS, Wasm and every Native target. The host’s locale APIs never run unless you ask for them. The data is pinned to one CLDR release, so a result changes when we bump the library, not when a user updates their phone.
val date = LocalDate(2026, 7, 27)
date.format(FormatStyle.FULL, Locale.forLanguageTag("pt-BR"))
// segunda-feira, 27 de julho de 2026
date.format(FormatStyle.MEDIUM, Locale.forLanguageTag("ja"))
// 2026/07/27
Those two calls return those two strings in the app and on the web, because neither one asks the platform anything.
From a datetime gap to full standardization
It started narrow. All we needed was one date formatted the same on three surfaces. Once the data lived in Kotlin, every other part of localization was the same shape of problem, so we kept going. kotlinx-locale now does countries, currencies, numbers, time zones, relative and duration wording, person names and collation, all from the same CLDR data on every target.
Two pieces reach past CLDR on purpose. Currency identity, the numeric codes and the minor units a currency rounds to, is not in CLDR, so we vendor the official ISO 4217 list and generate from it, cross-checked against CLDR and the JDK where they overlap. Phone numbers are not a Unicode standard at all. They are Google’s libphonenumber, which might be standardized one day and is not today. We took it anyway. A localization library that cannot tell you whether a phone number is valid in a country is not finished.
At that point the goal had moved. It stopped being “format a date the same everywhere” and became one answer for anything locale-sensitive, identical on every Kotlin platform.
It is open source
kotlinx-locale is written entirely in common Kotlin and released under Apache 2.0. The code, the modules and the full API are on GitHub. If you build on Kotlin Multiplatform and your surfaces keep disagreeing about dates, currencies or numbers, it is for you.