
Locale-aware formatting for dates, times, numbers, countries and currencies using CLDR-sourced data; skeleton patterns, plural rules, native digits, phone-number parsing and versatile serialization.
Locale-aware dates, countries and currencies for Kotlin Multiplatform, written entirely in common Kotlin.
Kotlin has no multiplatform locale API. There is no common Locale type, and
kotlinx-datetime deliberately
ships no locale data, so common code has no way to turn a date into
"27 de julho de 2026" or "2026年7月27日". This library fills that gap. The data
comes from Unicode's CLDR, compiled into Kotlin source by a generator, so the
same call returns the same string on JVM, Android, JS, Wasm and every Native
target. The host's own locale APIs are never involved unless you ask for them.
import dev.carcara.kotlinx.locale.Locale
import dev.carcara.kotlinx.locale.datetime.*
import dev.carcara.kotlinx.locale.datetime.cldr.*
import kotlinx.datetime.*
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
date.format(FormatStyle.LONG, Locale.forLanguageTag("ar-EG"))
// ٢٧ يوليو ٢٠٢٦
LocalDateTime(date, LocalTime(15, 5)).format(FormatStyle.SHORT, Locale.current)
// 7/27/26, 3:05 PM (on an en-US machine)Countries and currencies work the same way, with their own imports:
import dev.carcara.kotlinx.locale.country.*
import dev.carcara.kotlinx.locale.country.cldr.*
import dev.carcara.kotlinx.locale.currency.*
import dev.carcara.kotlinx.locale.currency.cldr.*
Country.forAlpha3("BRA") // Country.BR
Country.BR.displayName(Locale.forLanguageTag("fr")) // Brésil
Country.BR.currency // Currency.BRL
val price = CurrencyAmount(Currency.EUR, 123456) // 1234.56 in minor units
price.format(Locale.forLanguageTag("de")) // 1.234,56 €
price.format(Locale.forLanguageTag("en")) // €1,234.56Every function, parameter and default is listed in API.md.
Dates and times:
FULL, LONG, MEDIUM, SHORT) for dates,
times and date-times across all 1121 CLDR locales.date.format("yMMMd", ptBR) is "27 de jul. de 2026" and the same call in ja
is "2026年7月27日". The chosen pattern is available on its own, so it can drive
kotlinx-datetime's DateTimeFormat. Opt in through a separate artifact.июля inside a sentence
and the nominative июль on a calendar header, and Croatian writes its
stand-alone narrow months as numbers.3 days ago, včera, za 10 dní, with the plural rules
that pick among a language's forms.Pacific Standard Time, PT, the localized GMT format, and
the exemplar cities behind Los Angeles Time.Countries:
Numbers:
12,5 % with a
no-break space, Turkish reads %12,5, and 1200 compacts to 1.2K.1st, 1., 1º.Languages:
Currencies:
$1,234.56, 1.234,56 €,
₹1,23,456.78, ١٬٢٣٤٫٥٦ ج.م., with accounting and cash variants. CHF
cash rounds to 0.05.200 Ft becomes 20000,
because HUF prints without its two ISO decimals.Locales:
Locale type that parses BCP 47 tags and POSIX identifiers, with CLDR
fallback. pt-XX falls back to pt, and an unknown language to CLDR root.Locale.current reads the system locale. This is the project's single
expect/actual: one function per platform returns a raw tag, and everything
else runs in commonMain.Serialization:
Locale, Country, Currency and
CurrencyAmount, in artifacts of their own. Nothing else in the library
depends on them, so a build that serializes none of these types carries no
serialization runtime.The artifacts are not on Maven Central yet. Until they are, clone this repository and publish to your local Maven repository:
./gradlew publishToMavenLocalThen add mavenLocal() to the consuming build and depend on 0.1.0-SNAPSHOT.
The coordinates below are the ones the artifacts will carry when they ship, so
nothing but the repository line changes later.
Everything published, ready to paste into gradle/libs.versions.toml:
[versions]
kotlinx-locale = "0.1.0-SNAPSHOT"
[libraries]
# The Locale type and the LocaleDataSource contract. Everything depends on this.
locale-core = { module = "dev.carcara:kotlinx-locale-core", version.ref = "kotlinx-locale" }
# The generated locale catalog: PT.BR instead of "pt-BR". Optional.
locale-types = { module = "dev.carcara:kotlinx-locale-types", version.ref = "kotlinx-locale" }
# What the host can say about locales before any domain is involved.
locale-platform = { module = "dev.carcara:kotlinx-locale-platform", version.ref = "kotlinx-locale" }
# kotlinx.serialization strategies for Locale. Optional, and depended on by nothing else.
locale-serialization = { module = "dev.carcara:kotlinx-locale-serialization", version.ref = "kotlinx-locale" }
# Country
locale-country-types = { module = "dev.carcara:kotlinx-locale-country-types", version.ref = "kotlinx-locale" }
locale-country-core = { module = "dev.carcara:kotlinx-locale-country-core", version.ref = "kotlinx-locale" }
locale-country-cldr-runtime = { module = "dev.carcara:kotlinx-locale-country-cldr-runtime", version.ref = "kotlinx-locale" }
locale-country-cldr-full = { module = "dev.carcara:kotlinx-locale-country-cldr-full", version.ref = "kotlinx-locale" }
locale-country-platform = { module = "dev.carcara:kotlinx-locale-country-platform", version.ref = "kotlinx-locale" }
locale-country-serialization = { module = "dev.carcara:kotlinx-locale-country-serialization", version.ref = "kotlinx-locale" }
# Language, script and region names
locale-language-core = { module = "dev.carcara:kotlinx-locale-language-core", version.ref = "kotlinx-locale" }
locale-language-cldr-runtime = { module = "dev.carcara:kotlinx-locale-language-cldr-runtime", version.ref = "kotlinx-locale" }
locale-language-cldr-full = { module = "dev.carcara:kotlinx-locale-language-cldr-full", version.ref = "kotlinx-locale" }
# Numbers, plurals and ordinals
locale-number-core = { module = "dev.carcara:kotlinx-locale-number-core", version.ref = "kotlinx-locale" }
locale-number-cldr-runtime = { module = "dev.carcara:kotlinx-locale-number-cldr-runtime", version.ref = "kotlinx-locale" }
locale-number-cldr-full = { module = "dev.carcara:kotlinx-locale-number-cldr-full", version.ref = "kotlinx-locale" }
# Currency
locale-currency-types = { module = "dev.carcara:kotlinx-locale-currency-types", version.ref = "kotlinx-locale" }
locale-currency-core = { module = "dev.carcara:kotlinx-locale-currency-core", version.ref = "kotlinx-locale" }
locale-currency-cldr-runtime = { module = "dev.carcara:kotlinx-locale-currency-cldr-runtime", version.ref = "kotlinx-locale" }
locale-currency-cldr-full = { module = "dev.carcara:kotlinx-locale-currency-cldr-full", version.ref = "kotlinx-locale" }
# Currency names that agree with a count, on top of -cldr-full. Opt in.
locale-currency-cldr-plurals = { module = "dev.carcara:kotlinx-locale-currency-cldr-plurals", version.ref = "kotlinx-locale" }
locale-currency-platform = { module = "dev.carcara:kotlinx-locale-currency-platform", version.ref = "kotlinx-locale" }
locale-currency-serialization = { module = "dev.carcara:kotlinx-locale-currency-serialization", version.ref = "kotlinx-locale" }
# Date and time
locale-datetime-core = { module = "dev.carcara:kotlinx-locale-datetime-core", version.ref = "kotlinx-locale" }
locale-datetime-cldr-runtime = { module = "dev.carcara:kotlinx-locale-datetime-cldr-runtime", version.ref = "kotlinx-locale" }
locale-datetime-cldr-full = { module = "dev.carcara:kotlinx-locale-datetime-cldr-full", version.ref = "kotlinx-locale" }
# Skeleton formatting, on top of -cldr-full. Opt in.
locale-datetime-cldr-skeletons = { module = "dev.carcara:kotlinx-locale-datetime-cldr-skeletons", version.ref = "kotlinx-locale" }
# Relative wording, on top of -cldr-runtime rather than -cldr-full. Opt in.
locale-datetime-cldr-relative = { module = "dev.carcara:kotlinx-locale-datetime-cldr-relative", version.ref = "kotlinx-locale" }
# Duration wording, on the same footing as the relative tables. Opt in.
locale-datetime-cldr-durations = { module = "dev.carcara:kotlinx-locale-datetime-cldr-durations", version.ref = "kotlinx-locale" }
# Date and time ranges, on top of the skeletons. Opt in.
locale-datetime-cldr-intervals = { module = "dev.carcara:kotlinx-locale-datetime-cldr-intervals", version.ref = "kotlinx-locale" }
locale-datetime-platform = { module = "dev.carcara:kotlinx-locale-datetime-platform", version.ref = "kotlinx-locale" }
# Time zone names
locale-timezone-core = { module = "dev.carcara:kotlinx-locale-timezone-core", version.ref = "kotlinx-locale" }
locale-timezone-cldr-runtime = { module = "dev.carcara:kotlinx-locale-timezone-cldr-runtime", version.ref = "kotlinx-locale" }
locale-timezone-cldr-full = { module = "dev.carcara:kotlinx-locale-timezone-cldr-full", version.ref = "kotlinx-locale" }
# The exemplar cities, on top of -cldr-full. Opt in.
locale-timezone-cldr-cities = { module = "dev.carcara:kotlinx-locale-timezone-cldr-cities", version.ref = "kotlinx-locale" }
# Phone numbers. The data is Google's libphonenumber rather than CLDR.
locale-personname-core = { module = "dev.carcara:kotlinx-locale-personname-core", version.ref = "kotlinx-locale" }
locale-personname-cldr-runtime = { module = "dev.carcara:kotlinx-locale-personname-cldr-runtime", version.ref = "kotlinx-locale" }
locale-personname-cldr-full = { module = "dev.carcara:kotlinx-locale-personname-cldr-full", version.ref = "kotlinx-locale" }
locale-phone-core = { module = "dev.carcara:kotlinx-locale-phone-core", version.ref = "kotlinx-locale" }
locale-phone-metadata-runtime = { module = "dev.carcara:kotlinx-locale-phone-metadata-runtime", version.ref = "kotlinx-locale" }
locale-phone-metadata-full = { module = "dev.carcara:kotlinx-locale-phone-metadata-full", version.ref = "kotlinx-locale" }
locale-phone-serialization = { module = "dev.carcara:kotlinx-locale-phone-serialization", version.ref = "kotlinx-locale" }
[bundles]
# Bundled CLDR data: the normal choice.
locale-country-cldr = ["locale-country-types", "locale-country-core", "locale-country-cldr-full"]
locale-currency-cldr = ["locale-currency-types", "locale-currency-core", "locale-currency-cldr-full"]
# The same, plus the names that agree with a count: "2 US dollars".
locale-currency-plurals = ["locale-currency-types", "locale-currency-core", "locale-currency-cldr-plurals"]
locale-datetime-cldr = ["locale-datetime-core", "locale-datetime-cldr-full"]
# The same, plus skeleton formatting.
locale-datetime-skeletons = ["locale-datetime-core", "locale-datetime-cldr-full", "locale-datetime-cldr-skeletons"]
# Relative wording, which needs no date patterns.
locale-datetime-relative = ["locale-datetime-core", "locale-datetime-cldr-relative"]
# Duration wording, which needs none either.
locale-datetime-durations = ["locale-datetime-core", "locale-datetime-cldr-durations"]
# Ranges, which pull the skeletons in because an interval is a split of one.
locale-datetime-intervals = ["locale-datetime-core", "locale-datetime-cldr-full", "locale-datetime-cldr-skeletons", "locale-datetime-cldr-intervals"]
locale-personname-cldr = ["locale-personname-core", "locale-personname-cldr-full"]
locale-language-cldr = ["locale-language-core", "locale-language-cldr-full"]
locale-number-cldr = ["locale-number-core", "locale-number-cldr-full"]
# Zone names. The second adds the exemplar cities, which is the larger half.
locale-timezone-cldr = ["locale-timezone-core", "locale-timezone-cldr-full"]
locale-timezone-cities = ["locale-timezone-core", "locale-timezone-cldr-cities"]
locale-phone = ["locale-phone-core", "locale-phone-metadata-full"]
# The host's data instead, shipping no tables.
locale-country-host = ["locale-country-types", "locale-country-core", "locale-country-platform"]
locale-currency-host = ["locale-currency-types", "locale-currency-core", "locale-currency-platform"]
locale-datetime-host = ["locale-datetime-core", "locale-datetime-platform"]
# For a build that generates its own narrowed data with the Gradle plugin.
locale-country-narrowed = ["locale-country-types", "locale-country-core", "locale-country-cldr-runtime"]
locale-currency-narrowed = ["locale-currency-types", "locale-currency-core", "locale-currency-cldr-runtime"]
locale-datetime-narrowed = ["locale-datetime-core", "locale-datetime-cldr-runtime"]
locale-language-narrowed = ["locale-language-core", "locale-language-cldr-runtime"]
locale-number-narrowed = ["locale-number-core", "locale-number-cldr-runtime"]
locale-personname-narrowed = ["locale-personname-core", "locale-personname-cldr-runtime"]
locale-timezone-narrowed = ["locale-timezone-core", "locale-timezone-cldr-runtime"]
# There is no locale-phone-narrowed: the phone metadata is keyed by territory
# rather than by locale, so declaring three locales narrows nothing about it.
# Take locale-phone directly, at 76 KB for every territory in the world.
[plugins]
# Generates a data set narrowed to the locales a build declares.
kotlinx-locale = { id = "dev.carcara.kotlinx-locale", version.ref = "kotlinx-locale" }repositories {
mavenLocal()
mavenCentral()
}
kotlin {
sourceSets.commonMain.dependencies {
implementation(libs.bundles.locale.datetime.cldr)
implementation(libs.bundles.locale.country.cldr)
implementation(libs.bundles.locale.currency.cldr)
}
}Without a version catalog, the same thing written out:
kotlin {
sourceSets.commonMain.dependencies {
implementation("dev.carcara:kotlinx-locale-datetime-core:0.1.0-SNAPSHOT")
implementation("dev.carcara:kotlinx-locale-datetime-cldr-full:0.1.0-SNAPSHOT")
}
}Each layer depends on the ones below it with api, so -cldr-full on its own
resolves the whole set. The bundles list all three because a dependency block
that names what it uses is easier to audit than one that relies on transitives.
There is no umbrella artifact. An artifact whose only job is to pull three others is a second place for the dependency set to be wrong, and a catalog bundle does the same job in the build where the versions already live.
Only the JVM variant, since Maven cannot resolve Kotlin Multiplatform metadata:
<dependency>
<groupId>dev.carcara</groupId>
<artifactId>kotlinx-locale-datetime-cldr-full-jvm</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>Built against Kotlin 2.4.0. The datetime modules expose kotlinx-datetime 0.8.0
as an api dependency, so LocalDate in your code and LocalDate in a format
call are the same type. The -serialization modules expose
kotlinx-serialization-core 1.11.0 the same way, for the same reason: a
KSerializer<Country> you can name is one your own compile classpath has to
know about.
They need the runtime and nothing else. Their serializers are written by hand,
so the serialization compiler plugin is not applied to a single published source
file here. Apply it in your own build if you write @Serializable classes, as
you already would.
Artifacts are named kotlinx-locale[-<domain>]-<layer>. Every domain has the
same layers, sorted along one axis: who supplies the data. The translated text,
which is the part that weighs anything, lives in exactly one of them.
kotlinx-locale-currency-types generated enums
kotlinx-locale-currency-core the contract
kotlinx-locale-currency-cldr-runtime the engine, no data
kotlinx-locale-currency-cldr-full the engine plus 1121 locales
kotlinx-locale-currency-cldr-plurals the same, plus the count-keyed names
kotlinx-locale-currency-platform the host supplies it
Nothing at a call site says which layer answered, which is the point.
Country.BR.alpha3 reads from -types, Country.forAlpha3("BRA") from -core
and Country.BR.displayName(locale) from -cldr-full, and all three are
written the same way. Generated types carry only their per-entry data;
everything else about them is an extension, so a declaration can move between
layers without touching a call site.
The -cldr-full row is one of three ways to get the tables, not the only one.
The other two are the Gradle plugin, which generates the same tables and the
same entry points for the locales a build declares, and the -platform layer,
which answers from the host and ships nothing. The -core and -cldr-runtime
layers are common to the first two, so
Shipping only the locales you use is a
dependency change rather than a different API. API.md names the
plugin flag beside the artifact for every domain that has one.
| Module | What it contains |
|---|---|
kotlinx-locale-core |
The Locale type: tag parsing, normalization, system locale detection, the fallback chain, and the LocaleDataSource contract every data source answers. Depends on nothing. |
kotlinx-locale-platform |
What the host can say about locales before any domain is involved: whether it exposes locale data, and which locales it enumerates. |
kotlinx-locale-types |
The generated locale catalog: one enum per language, so PT.BR names a locale the compiler checks instead of a string that fails at runtime. Optional, and generatable: a build that ships a handful of locales can write its own with catalog = true. |
kotlinx-locale-serialization |
LocaleTagSerializer, which writes a Locale as its BCP 47 tag and reads one as leniently as Locale.forLanguageTag does. |
kotlinx-locale-country-types |
The Country enum: 249 ISO 3166-1 entries carrying their alpha-3 and numeric codes. Generated, and nothing else. |
kotlinx-locale-country-core |
alpha2, the for* lookups, and CountryNameSource with the total operations and the fallback composer over it. |
kotlinx-locale-country-cldr-runtime |
The country-name lookup over CLDR-shaped name records, and none of the records. The table is a constructor argument, which is what a narrowed build binds its own to. |
kotlinx-locale-country-cldr-full |
-cldr-runtime plus the CLDR name tables for all 1121 locales: CldrCountry and Country.displayName. |
kotlinx-locale-country-platform |
PlatformCountry: country names from java.util.Locale, Intl.DisplayNames or NSLocale. Ships no tables. |
kotlinx-locale-country-serialization |
One Country serializer per ISO 3166-1 code (alpha-2, alpha-3, numeric), plus a lenient reader that takes all three and writes alpha-2. |
kotlinx-locale-language-core |
LanguageNameSource and the locale display name algorithm of UTS #35 Part 2: how a language name and its unconsumed subtags compose into Serbian (Cyrillic). |
kotlinx-locale-language-cldr-runtime |
The language, script and region name lookup over CLDR-shaped records it does not carry. |
kotlinx-locale-language-cldr-full |
-cldr-runtime plus the name tables: CldrLanguage, Locale.displayName and Locale.nativeDisplayName. The largest table in the library, which is the strongest argument for the Gradle plugin. |
kotlinx-locale-number-core |
Decimal, NumberSymbols, PluralCategory, SignDisplay and the number, plural and ordinal contracts. Its own README records where each part of the behaviour is defined, because CLDR settles the data and not the option names. |
kotlinx-locale-number-cldr-runtime |
The CLDR pattern engine, the compact algorithm, the plural rule evaluator and the ordinal rule evaluator. The currency domain formats through this one rather than through a copy. |
kotlinx-locale-number-cldr-full |
-cldr-runtime plus the symbol, pattern, compact, plural and ordinal tables: CldrNumber, numberFormat, numberFormatPercent, numberOrdinal, numberSymbols and pluralCategory. |
kotlinx-locale-currency-types |
The Currency enum (both ISO 4217 lists, ISO minor units, CLDR fraction and cash-rounding behavior, tender windows) and the country-to-currency map. |
kotlinx-locale-currency-core |
code, minorUnitDigits, the ISO/CLDR scale conversions, the for* lookups, CurrencyAmount and its arithmetic, and the CurrencyNameSource and CurrencyFormatSource contracts. |
kotlinx-locale-currency-cldr-runtime |
The symbol and name lookup plus the pattern-based number formatter and parser, over CLDR-shaped records it does not carry. |
kotlinx-locale-currency-cldr-full |
-cldr-runtime plus the CLDR symbol, name and number tables for all 1121 locales: CldrCurrency, Currency.symbol, Currency.displayName and CurrencyAmount.format. |
kotlinx-locale-currency-cldr-plurals |
-cldr-full plus CLDR's count-keyed currency names and the patterns that join one to a number: CldrCurrencyPlurals, CurrencyAmount.formatPluralName and Currency.pluralName. |
kotlinx-locale-currency-platform |
PlatformCurrency: symbols, names and number formatting from NumberFormat, Intl.NumberFormat or NSNumberFormatter. Ships no tables. |
kotlinx-locale-currency-serialization |
The Currency serializers (alphabetic code, numeric code, lenient) and the three CurrencyAmount forms. Locale-independent throughout, and so free of CLDR. |
kotlinx-locale-datetime-core |
FormatStyle, TextStyle and the DateTimeFormatSource contract. The only module that depends on kotlinx-datetime. |
kotlinx-locale-datetime-cldr-runtime |
The pattern parser and formatter plus the record lookup, over CLDR-shaped records it does not carry. |
kotlinx-locale-datetime-cldr-full |
-cldr-runtime plus the CLDR pattern data for all 1121 locales: CldrDateTime, LocalDate.format and friends. |
kotlinx-locale-datetime-cldr-skeletons |
-cldr-full plus the skeleton tables: CldrDateTimeSkeletons and date.format("yMMMd", locale), where you name the fields and the locale decides their order. Opt in, at around 58 KB gzipped on top of -cldr-full. |
kotlinx-locale-datetime-cldr-intervals |
CldrDateTimeIntervals and intervalFormat: Jul 18 – 22, 2026, with the parts both ends share written once. Builds on -cldr-skeletons, since a range is a split of the pattern the matcher picks, and adds around 28 KB gzipped over it. |
kotlinx-locale-personname-core |
PersonName and the option enums, plus PersonNameSource. No data. |
kotlinx-locale-personname-cldr-runtime |
Pattern selection, field modifiers and the empty-field cleanup. Carries no records. |
kotlinx-locale-personname-cldr-full |
CldrPersonName, personNameFormat and personNameOrder: a name written the way a locale writes one, and its initials. |
kotlinx-locale-datetime-cldr-relative |
CldrRelativeTime and relativeTimeFormat: 3 days ago and včera, with the plural rules that pick among a language's forms. Its own artifact because it needs no date patterns. |
kotlinx-locale-datetime-cldr-durations |
CldrDurationUnits and durationFormat: 2 hours, 2 hr, 2h, across fourteen time units and three widths. Its own artifact for the same reason as the relative tables, and around 117 KB gzipped. Not the same thing as durationPattern, which gives h:mm and ships with -cldr-full. |
kotlinx-locale-datetime-platform |
PlatformDateTime: the four lengths and the calendar names from DateTimeFormatter, Intl.DateTimeFormat or NSDateFormatter. Ships no tables. |
kotlinx-locale-timezone-core |
TimeZoneNameSource and TimeZoneNameStyle: the forms UTS #35 Part 4 defines for naming a zone. |
kotlinx-locale-timezone-cldr-runtime |
The localized GMT format, metazone resolution and the naming ladder, over records it does not carry. |
kotlinx-locale-timezone-cldr-full |
-cldr-runtime plus the format and name tables: CldrTimeZone, TimeZone.displayName and UtcOffset.displayName. |
kotlinx-locale-timezone-cldr-cities |
-cldr-full plus the exemplar cities, for the generic location format. Opt in: this is the largest zone table, and without it the format falls back to the identifier's own last part, which is what the spec prescribes. |
kotlinx-locale-phone-core |
PhoneNumber, PhoneNumberType, PhoneNumberFormat and PhoneNumberSource. Keyed by country rather than by locale, because a number is valid or not whoever is reading it. |
kotlinx-locale-phone-metadata-runtime |
The parser, the validator, the formatters, the as-you-type formatter, and the bounded pattern matcher they all run on. |
kotlinx-locale-phone-metadata-full |
-metadata-runtime plus every territory libphonenumber describes: PhoneNumbers, phoneNumberOrNull and Country.asYouType. |
kotlinx-locale-phone-serialization |
One serializer per written form, a lenient one that reads all four, and a metadata-free one over the parts. No default: the forms carry different amounts of information. |
kotlinx-locale-codegen-emitters |
The emitters and the bundle reader: the half of code generation a build can run. Parses no XML and clones nothing, so it is safe on a build classpath. |
kotlinx-locale-codegen-data |
CLDR resolved into one compact record per locale, versioned by the release it came from. What a build reads instead of cloning CLDR. |
kotlinx-locale-gradle-plugin |
The dev.carcara.kotlinx-locale plugin, which generates a data set narrowed to the locales a build declares. |
Anything named kotlinx-locale-codegen-* runs at build time and never belongs
on an application classpath.
Each published module lives in a directory of the same name, so a listing of
this repository's root reads like a listing on Maven Central. A directory
without the prefix publishes nothing: conformance-test-suite/ holds the ICU
fixtures and the assertions this repo's own test source sets run, codegen/ is
the extraction half of code generation that clones CLDR and ICU, tools/ holds
the Kotlin/JS size probes, and build-logic/ is the included build with the
convention plugins.
No artifact name is a prefix of another at a hyphen boundary, because Kotlin
Multiplatform already owns that suffix space: every module publishes one
artifact per target, so -jvm and -iosarm64 sit beside the bare coordinate on
Maven Central. settings.gradle.kts enforces it at configuration time.
Where this behaviour is defined is worth saying, because it is two places. The
vocabulary is normative: the field letters of yMMMd are UTS #35's Date Field
Symbol Table and availableFormats is an LDML element. The matching algorithm
is not. How a missing field is weighed against a wrong width, and which of M
and L a locale's own pattern imposes, come from ICU's
DateTimePatternGenerator, which is why :codegen checks out ICU's source and
extracts goldens from it. The number domain has the same split for the same
reason, and kotlinx-locale-number-core/README.md sets out the argument for
following ICU where LDML is silent.
FormatStyle offers four fixed lengths. A skeleton instead names the fields you
want, in no particular order, and the locale decides how to arrange them. This is
what DateFormat.getBestDateTimePattern gives an Android developer and
setLocalizedDateFormatFromTemplate an iOS one.
import dev.carcara.kotlinx.locale.datetime.cldr.skeletons.*
date.format("yMMMd", Locale.forLanguageTag("pt-BR")) // "27 de jul. de 2026"
date.format("yMMMd", Locale.forLanguageTag("ja")) // "2026年7月27日"
date.format("MMMEd", Locale.forLanguageTag("en")) // "Mon, Jul 27"The letters are CLDR's: y year, M month, d day, E weekday, Q quarter,
h and H hour, m minute, s second, G era. Repeating one asks for a
width, so MMM is an abbreviated month name and MMMM a full one. j asks for
whichever hour the locale prefers together with the day period that goes with
it, which is usually what you want:
time.format("jm", Locale.forLanguageTag("en")) // "3:05 PM", with U+202F before PM
time.format("jm", Locale.forLanguageTag("en-GB")) // "15:05"The pattern is available on its own, not only the formatted string:
skeletonPatternOrNull("yMMMd", Locale.forLanguageTag("pt-BR")) // "d 'de' MMM 'de' y"
skeletonPatternOrNull("yMd", Locale.forLanguageTag("pt-BR")) // "dd/MM/y"A numeric pattern composes with kotlinx-datetime today, which buys locale-aware parsing off the same table:
LocalDate.Format { byUnicodePattern(skeletonPatternOrNull("yMd", ptBR)!!) }A pattern naming a month or a weekday does not. byUnicodePattern rejects MMM
and EEE with "the directive is locale-dependent, but locales are not supported
in Kotlin", which is the gap this library fills on the formatting side and does
not yet fill on the parsing side. Formatting is one-way for anything with a name
in it.
Time zones, week numbers and fractional seconds are out of scope, because a
LocalDate carries no zone and week numbering needs data this library does not
ship, so a skeleton naming one of those is refused rather than answered a field
short.
The matcher is the algorithm from UTS #35 written in common Kotlin. Nothing delegates to ICU at runtime; the agreement between the two is a test, held to patterns generated from ICU4J across 859 locales and 109 skeletons on all eight targets, plus CLDR's own datetime cases.
The tables live in their own artifact so that a consumer of ordinary date
formatting does not pay for skeletons. Measured by the probes in tools/, in
gzipped bundle size:
| take | size | added |
|---|---|---|
-cldr-full |
127.3 KB | |
plus -cldr-skeletons
|
185.8 KB | 58.5 KB |
plus -cldr-intervals
|
213.3 KB | 27.5 KB |
Each layer builds on the one above it rather than repeating its tables, so
asking for intervals brings the skeletons and the patterns with it. That is not
a packaging accident: formatting a range means picking a pattern for the
requested skeleton and then rendering both halves, which needs the month names,
weekday names and digits that -cldr-full carries.
The algorithms sit in -cldr-runtime instead, for the same reason the pattern
formatter already does. A build narrowed through the Gradle plugin depends on
-cldr-runtime alone and takes none of these three artifacts: it generates its
own tables, and each feature declares the whole closure it needs, so
datetime { intervals = true } generates the pattern, skeleton and interval
tables together. The numbers above are the cost of the bundled path, not of the
narrowed one.
Every domain has a -platform layer that answers from the host rather than from
bundled tables: java.util.Locale and java.time on JVM and Android, Intl on
JS and Wasm/JS, Foundation on Apple. Nothing ships, and in exchange the answers
are whatever the device says.
import dev.carcara.kotlinx.locale.datetime.platform.*
date.format(FormatStyle.LONG, Locale.forLanguageTag("pt-BR"))The same call as the CLDR version with a different import, which is what the package split buys.
What it saves, measured by the Kotlin/JS probes in tools/ making identical
calls against each layer:
| domain | platform | CLDR | saved |
|---|---|---|---|
| datetime | 35.3 KB | 127.3 KB | 92.0 KB |
| country | 20.2 KB | 376.4 KB | 356.2 KB |
| currency | 25.1 KB | 442.8 KB | 417.7 KB |
| all three | 49.1 KB | 911.0 KB | 861.9 KB |
Gzipped over the minified bundle. Datetime saves the least because
kotlinx-datetime sits in both numbers and only the formatting moved.
docs/size.md has the full table and is regenerated from the
build rather than typed.
Read that CLDR column as the price of all 1121 locales, not the price of CLDR.
Most products ship a language picker with a handful of entries in it, and the
Gradle plugin generates the data for exactly those, against the same
-cldr-runtime engine the full artifact uses. The three-locale build in
samples/narrowed/ generates 205 KB of tables where the shipped modules it
replaces hold 6699 KB, roughly a thirty-third of the data, and formats
identically for the locales it kept because it is running the same code over a
smaller table.
So the choice is not the two columns above. It is three:
| what ships | answers | when it fits | |
|---|---|---|---|
-platform |
nothing | whatever the host says, with the gaps below | you do not know the locale set, or you want it to track the device |
-cldr-full |
all 1121 locales | the same on every target | you need arbitrary locales at runtime |
plugin plus -cldr-runtime
|
only the locales you named | the same on every target | you know the set at build time, which is most products |
Shipping only the locales you use covers how to set the third one up.
Those 205 KB and 6699 KB are Kotlin source, counted from the sample and from the
internal/data trees it replaces. The size probes in tools/ do not cover a
narrowed build, so there is no gzipped bundle figure for it to sit beside the
table above.
Skeleton formatting is CLDR only, and that is a decision rather than a gap. The
hosts will format from a template, but none of them hands back the pattern it
chose, and half of what makes a skeleton useful is reusing that pattern for
parsing. A build that wants skeletons takes
kotlinx-locale-datetime-cldr-skeletons.
Two things to know before choosing it.
Platform sources are partial, for two different reasons.
Locale data is not wired up on Linux, Windows, Android Native or Wasm-WASI yet, so every lookup misses on those four. That is a gap rather than a verdict on the platforms, and contributions are welcome.
The rest is the shape of the host APIs. Cash rounding is not a platform concept
anywhere. Accounting formats exist on Intl and Foundation but not in
java.text. Currency parsing exists only where it is exact, which is JVM and
Android.
Either way a miss is the signal the Fallback* composers read:
val dates = FallbackDateTimeFormats(primary = PlatformDateTime, fallback = CldrDateTime)Composition does not round trip across sources. Foundation writes ¥ for JPY in
ja where CLDR writes the fullwidth ¥, so a string one produced is not
necessarily one the other parses. Formatting with the platform and parsing with
CLDR is not something the library promises.
Three artifacts carry kotlinx.serialization strategies, one per domain, and nothing else in the library depends on them:
import dev.carcara.kotlinx.locale.serialization.*
import dev.carcara.kotlinx.locale.country.serialization.*
import dev.carcara.kotlinx.locale.currency.serialization.*
@Serializable
data class Order(
@Serializable(with = LocaleTagSerializer::class) val locale: Locale,
@Serializable(with = CountryAlpha3Serializer::class) val shipTo: Country,
@Serializable(with = CurrencyAmountMinorUnitsSerializer::class) val total: CurrencyAmount,
)
val order = Order(
locale = Locale.forLanguageTag("pt-BR"),
shipTo = Country.BR,
total = CurrencyAmount(Currency.BRL, 1234_56),
)
Json.encodeToString(order)
// {"locale":"pt-BR","shipTo":"BRA","total":{"currency":"BRL","minorUnits":123456}}Country and Currency need none of this to serialize at all. Their entry
names are the alpha-2 and the ISO 4217 alphabetic code, and the plugin writes an
enum as its entry name, so an unannotated property already produces "BR" and
"BRL". What the named serializers add is a contract: CountryAlpha3Serializer
on a field says alpha-3 out loud, and it fails on the day something sends
"BR" instead of quietly accepting it.
Every serializer says which representation it is, and there is no unnamed one to fall into by accident:
| Type | Serializer | JSON |
|---|---|---|
Locale |
LocaleTagSerializer |
"pt-BR" |
Country |
CountryAlpha2Serializer |
"US" |
CountryAlpha3Serializer |
"USA" |
|
CountryNumericCodeSerializer |
840 |
|
CountryLenientCodeSerializer |
reads all three, writes "US"
|
|
Currency |
CurrencyCodeSerializer |
"USD" |
CurrencyNumericCodeSerializer |
840 |
|
CurrencyLenientCodeSerializer |
reads both, writes "USD"
|
|
CurrencyAmount |
CurrencyAmountMinorUnitsSerializer |
{"currency":"USD","minorUnits":123456} |
CurrencyAmountDecimalSerializer |
{"currency":"USD","amount":"1234.56"} |
|
CurrencyAmountCodeAndDecimalSerializer |
"USD 1234.56" |
The two object forms of an amount differ in where the scale lives. minorUnits
is exact and needs no parsing, but 123456 is $1,234.56 only because the
Currency enum says USD has two minor units, so both ends have to agree on the
ISO data. The decimal string puts the scale in the payload, which is what a row
that outlives a release wants. The combined string is the one to reach for when
the amount has to fit a single scalar: a map key, a query parameter, a column
you would rather not split in two.
A country's three ISO code spaces do not overlap. Alpha-2 is two letters, alpha-3 is three, numeric is digits, so one reader can take any of them and still know which space it is in:
Json.decodeFromString(CountryLenientCodeSerializer, "\"US\"") // Country.US
Json.decodeFromString(CountryLenientCodeSerializer, "\"USA\"") // Country.US
Json.decodeFromString(CountryLenientCodeSerializer, "\"840\"") // Country.US
Json.decodeFromString(CountryLenientCodeSerializer, "\"004\"") // Country.AFIt writes alpha-2 whichever one it read, so the second time a row is written it is canonical. That is the migration: point the field at this serializer, and the old spellings drain out as rows are touched.
One limit is worth knowing before you rely on it. The lenient readers take the
numeric code as a string, "840". A JSON number 840 is a different token,
and a Decoder has to commit to decodeString or decodeInt before it can see
which one is coming. The format-agnostic API these are written against offers
no way to peek. So a bare number needs one of two things:
// Tell the format to be forgiving; it hands the unquoted token over as text.
Json { isLenient = true }.decodeFromString(CountryLenientCodeSerializer, "840")
// Or, better, name the serializer for the type the field actually holds.
Json.decodeFromString(CountryNumericCodeSerializer, "840")The second is the honest answer when the field is genuinely a number. The declaration then says what the field holds, and read time has nothing left to resolve.
None of the CurrencyAmount serializers touches Locale, and
kotlinx-locale-currency-serialization depends on no CLDR data. The two string
forms of an amount do different jobs. toDecimalString writes ASCII digits and
a . and nothing else; format(locale) writes what a person expects to read,
which on some locales means grouping separators, a symbol, Arabic-Indic digits
and a narrow no-break space.
Only the first can be a wire format. The second cannot be read back without
knowing which locale wrote it, and CLDR moves separators between releases, so an
amount stored under one release could come back a different number under the
next. "USD 1,234.56" is a SerializationException here, deliberately.
Most applications need a handful of locales, not 1121. The Gradle plugin generates the data set for the ones a build declares:
plugins {
id("dev.carcara.kotlinx-locale") version "0.1.0-SNAPSHOT"
}
kotlinxLocale {
locales(PT.BR, EN.US, JA)
fallback(EN.US)
packageName = "com.example.locale"
country { names = true }
currency { names = true; formats = true }
datetime { patterns = true; skeletons = true }
}skeletons implies patterns: matching a skeleton scores against the locale's
standard date and time patterns, and rendering the winner needs its month and
weekday names, so the two tables travel together.
The dependency block then takes -core, -types and -cldr-runtime and leaves
out -cldr-full, because the records come from the generator instead. Call
sites do not change: the generated source implements the same interfaces and
carries the same extensions, so Country.BR.displayName(locale) still reads the
same and only the import moves. samples/narrowed/ is a working build that does
this, at roughly a thirtieth of the data
for the three locales it declares.
fallback is required, and required to be one of the generated locales. Ask a
three-locale build for de and it answers in the fallback rather than returning
nothing. That matters most for dates: a country or a currency can degrade to its
ISO code, but a date would surface as an ISO 8601 timestamp in the middle of a
translated screen.
Every domain that ships a -cldr-full artifact has flags of its own: country,
currency, datetime, language, number, time zones and person names. Phone metadata
does not, because it is keyed by territory rather than by locale, so a locale set
narrows nothing about it. API.md lists every flag next to
what it generates and the -cldr-runtime artifact it needs.
The flags above narrow locale data. The entry sets are separate, and shipped
whole by default: kotlinx-locale-types carries 322 enums for all 1121 CLDR
locales, Country carries 249 entries and Currency every ISO 4217 code. A
build that uses eight locales still gets all of them.
kotlinxLocale {
locales(PT.BR, EN.US)
fallback(EN.US)
catalog = true
country { entries(Country.BR, Country.US); names = true }
currency { entries(Currency.BRL, Currency.USD); names = true }
}catalog generates the locale catalog for the locales already declared, into
<packageName>.catalog, so naming a locale this build does not ship stops
compiling. It costs nothing else: nothing in the library extends a catalog enum,
so a build that generates its own leaves kotlinx-locale-types out of the
dependency block and that is the whole change.
entries is a real trade rather than a free saving, and worth reading twice
before turning on. Country and Currency keep the library's own package,
because kotlinx-locale-country-core declares Country.alpha2 and
Country.forAlpha2 on that exact name, so the plugin drops the shipped artifact
from the resolved classpath and the generated enum takes its place. You write
nothing in the dependency block for that. What you give up is representation: an
unlisted locale answers in the fallback, but there is no fallback for an unlisted
country. Country.forAlpha2OrNull("DE") returns null once DE is out of the enum,
so a build that parses ISO codes out of a payment API or a partner feed wants the
whole set.
The names follow the entries. Ask for two countries and the territory tables come out holding two names per locale rather than 249, which across every locale is the larger half of what narrowing the enum is for. API.md has the three flags next to what each replaces.
The build compiles and tests twenty-five targets. A release publishes six of them.
| Group | Targets |
|---|---|
| JVM |
jvm (toolchain 21), Android (compileSdk 36, minSdk 21) |
| Web | js |
| Apple |
iosArm64, iosSimulatorArm64, iosX64
|
js and wasmJs run in a browser as readily as under Node. Neither touches the
DOM or a Node built-in: Locale.current and the -platform modules go through
Intl, which every browser and every full-ICU Node build provides. The test
tasks use Node because it starts faster than a headless browser, which is a
choice about the test runner rather than about where the artifact works.
The other nineteen are missing from Maven Central rather than unsupported. Each one compiles and runs its tests on every push. They do not ship yet because Maven Central meters how many files an organization publishes each month, and twenty-five targets across forty-two multiplatform modules is twenty-six publications each, several times what that allows. The list follows the Kotlin/Native tiers and matches what kotlinx-datetime publishes.
| Group | Targets |
|---|---|
| Web |
wasmJs, wasmWasi
|
| Native tier 1 | macosArm64 |
| Native tier 2 |
linuxX64, linuxArm64, watchosArm32, watchosArm64, watchosSimulatorArm64, tvosArm64, tvosSimulatorArm64
|
| Native tier 3 |
androidNativeArm32, androidNativeArm64, androidNativeX86, androidNativeX64, mingwX64, watchosDeviceArm64
|
| Deprecated in Kotlin/Native |
macosX64, watchosX64, tvosX64
|
The last row is deprecated in Kotlin/Native but still published by kotlinx-datetime (KT-78660), so dropping it from the build would strand consumers who target it.
Only Locale.current and the -platform modules behave differently across
those two lists. Everything else is target-independent.
| Platform | Source |
|---|---|
| JVM and Android | java.util.Locale.getDefault() |
| Apple platforms |
NSLocale.preferredLanguages, then NSLocale.currentLocale
|
| JS and Wasm-JS | Intl.DateTimeFormat().resolvedOptions().locale |
| Linux and Android Native |
LC_ALL, LC_TIME, LANG
|
| Windows | GetUserDefaultLocaleName |
| Wasm-WASI | nothing exposed, so Locale.current returns en
|
🟢 the module answers from that target. 🟡 the lookup misses and the documented fallback applies.
The bundled modules first, because they are the flat row: pure common Kotlin, no expect/actual, the same answer on all 25 targets.
Time zone names are the one place where that flat row meets something the
platform owns. Naming a zone is pure common Kotlin like everything else here,
because it works from the identifier and the tables. Constructing a
kotlinx.datetime.TimeZone is not: each target reads whichever copy of the IANA
time zone database it has, and Kotlin/JS under Node has no full one, so
TimeZone.of("America/Los_Angeles") throws there for an identifier every other
target accepts. That is a property of the runtime rather than of this library,
and it is why the zone tests skip where a zone cannot be built.
| Module | JVM, Android | Apple | JS, Wasm-JS | Linux, Windows, Android Native, Wasm-WASI |
|---|---|---|---|---|
kotlinx-locale-core |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-types |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-country-cldr-full |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-currency-cldr-full |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-datetime-cldr-full |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-datetime-cldr-skeletons |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-serialization |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-country-serialization |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-currency-serialization |
🟢 | 🟢 | 🟢 | 🟢 |
Locale.current is the one exception in -core. It reads a real tag everywhere
except Wasm-WASI, which exposes nothing and so returns en.
The -platform modules are where the gaps are, and they are not uniform:
| Module | Operation | JVM, Android | Apple | JS, Wasm-JS | Linux, Windows, Android Native, Wasm-WASI |
|---|---|---|---|---|---|
kotlinx-locale-platform |
isAvailable |
🟢 true
|
🟢 true
|
🟢 true
|
🟡 false
|
availableLocaleTags() |
🟢 full list | 🟢 full list | 🟡 empty | 🟡 empty | |
kotlinx-locale-country-platform |
countryNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 |
kotlinx-locale-currency-platform |
currencySymbolOrNull |
🟢 | 🟢 | 🟢 | 🟡 |
currencyNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
formatOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
formatOrNull(accounting = true) |
🟡 | 🟢 | 🟢 | 🟡 | |
formatOrNull(cash = true) |
🟡 | 🟡 | 🟡 | 🟡 | |
parseToMinorUnitsOrNull |
🟢 | 🟡 | 🟡 | 🟡 | |
kotlinx-locale-datetime-platform |
formatDateOrNull |
🟢 | 🟢 | 🟢 | 🟡 |
formatTimeOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
formatDateTimeOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
monthNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
dayOfWeekNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
| skeleton formatting | not offered | not offered | not offered | not offered |
A 🟡 never surfaces as a null to your code. The total extensions layered in
-core fall back: country and currency names degrade to the ISO code, and dates
to ISO 8601. What a miss costs you is the localization, which is why the
Fallback* composers exist, and why pairing a -platform module with a bundled
one turns every 🟡 above back into a real answer:
val names = FallbackCountryNames(primary = PlatformCountry, fallback = CldrCountry)
val formats = FallbackCurrencyFormats(primary = PlatformCurrency, fallback = CldrCurrency)
val dates = FallbackDateTimeFormats(primary = PlatformDateTime, fallback = CldrDateTime)Skeleton formatting is the one row with no platform column at all. It is not
part of kotlinx-locale-datetime-core, so a -platform source cannot answer it
even in principle, for the reason given above: the hosts format from a template
but will not hand back the pattern they chose.
The 🟡s come from two different places, and the difference matters if you are deciding whether to wait for one to change.
Linux, Windows (mingwX64), Android Native and Wasm-WASI have no locale data
wired up yet, so all four return null for everything and report
isAvailable == false. This is the one gap that is simply unbuilt rather than
decided. Windows and Linux in particular do have locale facilities to read, and
this library already reads a little of both for Locale.current, so extending
that to names, currencies and dates is work waiting to be done rather than a
wall. If you want one of these targets, that is a contribution worth having.
Everything else below is a property of the host APIs and will not change by trying harder.
The empty availableLocaleTags() on JS and Wasm-JS is not a gap in the runtime.
ECMA-402 offers supportedLocalesOf to filter a list you already have but
nothing to ask for the list, so a source over Intl answers every lookup while
being unable to describe its coverage. That is why isAvailable and
availableLocaleTags() are separate questions.
Cash rounding is not a platform concept anywhere. CLDR knows that CHF cash
rounds to 0.05, and no host formatter does, so cash = true misses on every
target.
Accounting formats exist on Intl (currencySign) and Foundation
(NSNumberFormatterCurrencyAccountingStyle) but not in java.text, so
accounting = true misses on JVM and Android.
Currency parsing is offered only where it is exact. JVM and Android parse
through BigDecimal. Intl has no parser at all. Foundation's
numberFromString returns an NSNumber backed by a Double, which would
quietly lose minor units on large amounts, so Apple reports a miss rather than
round-tripping money through a Double. For the same reason there is no
throwing parseFormatted in the platform package, only parseFormattedOrNull.
Finally, a host that does not know a code tends to hand the code back rather
than admit it, which java.util.Locale does. An answer equal to the requested
code is treated as a miss, because the total operation already falls back to the
code and a composing source would otherwise take the echo for an answer and
never consult its fallback.
Which standard each module implements, with a link to the primary source for every one, is in docs/standards.md. The standard is the source of truth; this library is one reading of it, and where the two disagree the standard is right.
The :codegen module clones two official Unicode repositories into
codegen/repos/ (gitignored, sparse, pinned to release tags).
unicode-org/cldr at release-48-2 is the
source of truth. The generator parses the LDML files, resolves each locale's
inheritance chain (parentLocales rules, root aliases), and emits the flattened
result as encoded string constants into the datetime module, plus the locale tag
list into the base module. Identical payloads are deduplicated: 1121 locales
plus root collapse to 429 unique constants, around 500 KB of Kotlin source. The
same pipeline emits the Country and Currency enums, the localized
country and currency names, and the per-locale number-formatting data. Names are
stored sparsely, holding only what each locale's own file declares with the
parent chain walked at runtime, because flattening them would multiply the data
many times over.
unicode-org/icu at release-78.3 is used
only for verification. The generator extracts golden fixtures for 30 major
locales from ICU's resource bundles (datetime patterns and names, country
display names, currency symbols and names, number separators), and generated
Icu*GoldenTests verify in each module's commonTest that the CLDR-derived
runtime data agrees with them on every platform. ICU encodes the same upstream
data through a completely different pipeline, so agreement is a strong check on
the parsers and the runtime resolution. ICU's full currency numeric-code table
is emitted as a fixture too, so the ISO 4217 cross-check also runs as a test
everywhere. On the JVM, additional parity tests compare the ISO country and
currency tables against the JDK's own data, a third independent source.
Currency identity (numeric codes and ISO minor units) is not in CLDR, so the
official ISO 4217 XML published by SIX is vendored as a snapshot and parsed
during generation: codegen/src/main/resources/iso4217/list-one.xml for the
active codes and list-three.xml for the withdrawn ones, both published
2026-01-01. List three omits the minor units field entirely, so a withdrawn
code takes CLDR's fraction data instead, and the JDK parity test is what
confirms the two agree for the codes the JDK knows.
Flag emoji are not CLDR either. The RGI flag sequences of UTS #51 are vendored
the same way at codegen/src/main/resources/emoji/emoji-sequences.txt from
Emoji 17.0, and used only to check at generation time that every country's
derived sequence is one Unicode recommends. Nothing from that file is compiled
into an artifact. The country set is CLDR's regular region validity
list restricted to codes with an ISO alpha-3 and numeric assignment, which
excludes macroregions, exceptionally reserved codes (AC, IC) and
user-assigned codes (XK).
To regenerate after bumping the pinned tags in
codegen/src/main/kotlin/.../Repos.kt:
./gradlew :codegen:generateLocaleDataThe task clones on first run and reuses the clones afterwards. Generated files
carry a // GENERATED header and are committed, so consumers of the library
never run the pipeline.
./gradlew buildThis compiles every target and runs the test suite on each platform the host can execute: JVM, Android host tests, Node.js for JS and both Wasm targets, macOS, and the iOS and watchOS simulators. Apple simulator tests skip themselves when the matching runtime is not installed in Xcode.
The library modules share their target list and publishing setup through the
convention plugins in build-logic/, an included build rather than buildSrc
so that a change to one plugin only invalidates its consumers.
Formatting is enforced with ktlint;
generated sources are excluded by their // GENERATED header. Run
./gradlew ktlintFormat to fix style before committing, or
./gradlew ktlintCheck to verify.
Every source file opens with the Apache notice, and a script keeps all of them identical to the copy in LICENSE:
python3 scripts/license_header.py check # name the files whose header is wrong
python3 scripts/license_header.py apply # rewrite themThe notice is read out of LICENSE rather than written down a second time, so
editing the copyright line there and running apply updates the whole tree.
Generated sources carry it as well: the emitters write the same notice above the
// GENERATED line, so regenerating does not strip it.
Each library module records its public ABI under <module>/api/: one
.klib.api file covering every Kotlin/Native, JS and Wasm target, plus
jvm/<module>.api for the JVM bytecode.
./gradlew checkKotlinAbi # compare the sources against the recorded ABI
./gradlew updateKotlinAbi # rewrite it after a deliberate API changeCommit the rewritten files together with the code that changed them, so the diff
shows what the change does to the published surface. The check is not part of
check, so ./gradlew build skips it: a complete comparison needs a klib for
every target and only a macOS host can build them all, so running it elsewhere
would compare a subset and still report success.
Other tasks worth knowing:
./gradlew sizeReport # every artifact against its gzipped budget
./gradlew updateSizeDoc # regenerate docs/size.md from that report
./gradlew checkLayeringRule # hand-written code names no specific enum entry
./gradlew -p samples/narrowed build # the plugin sample, against local artifactsCI runs on every push to main and on pull requests: the license header check,
ktlint, the layering check, plugin validation and a configuration-cache round
trip; the size budgets; the tests of the generator, the emitters and the Gradle
plugin; the narrowed sample built against locally published artifacts; an ABI
check on macOS; and one job per Kotlin target, running that target's tests where
the runner can execute them and compiling it where it cannot. Pull requests must
be green on all of it before merging.
What is intended and not yet built is in ROADMAP.md; where this library has decided to stop is in docs/boundaries.md.
LocalDate is not supported. Currency strings do parse back:
CurrencyAmount.parseFormatted reads CLDR-formatted values like
R$ 1.234,56 or 200 Ft into ISO minor units, expecting one number with one
locale's separators rather than free-form text.kotlinx-locale-datetime-cldr-relative,
but you choose the unit. Whether ninety minutes reads as "in 90 minutes" or
"in 2 hours" is not standardized by CLDR, ECMA-402 or ICU, all of which take
the unit from the caller.kotlinx-locale-datetime-cldr-durations and takes
the unit from you for the same reason. It agrees with ICU on all 10080 cells
of its conformance fixture, which is fourteen units at three widths and eight
values across thirty locales. CLDR has wording for 681 of the 1121 locales;
the rest fall back to English, as ICU does for them.w, W and F pattern fields and the numeric forms of e and c need
goldens of their own before they can be turned on.¤¤¤ with a count) are not implemented, though
the plural rules they need are.-platform modules do not read locale data on Linux, Windows, Android
Native or Wasm-WASI yet. The bundled -cldr-* modules answer on all of them,
so this only affects a build that chose the host's data; see
what each module answers.Apache License 2.0. See LICENSE.
CLDR and ICU data is used under the Unicode License.
Locale-aware dates, countries and currencies for Kotlin Multiplatform, written entirely in common Kotlin.
Kotlin has no multiplatform locale API. There is no common Locale type, and
kotlinx-datetime deliberately
ships no locale data, so common code has no way to turn a date into
"27 de julho de 2026" or "2026年7月27日". This library fills that gap. The data
comes from Unicode's CLDR, compiled into Kotlin source by a generator, so the
same call returns the same string on JVM, Android, JS, Wasm and every Native
target. The host's own locale APIs are never involved unless you ask for them.
import dev.carcara.kotlinx.locale.Locale
import dev.carcara.kotlinx.locale.datetime.*
import dev.carcara.kotlinx.locale.datetime.cldr.*
import kotlinx.datetime.*
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
date.format(FormatStyle.LONG, Locale.forLanguageTag("ar-EG"))
// ٢٧ يوليو ٢٠٢٦
LocalDateTime(date, LocalTime(15, 5)).format(FormatStyle.SHORT, Locale.current)
// 7/27/26, 3:05 PM (on an en-US machine)Countries and currencies work the same way, with their own imports:
import dev.carcara.kotlinx.locale.country.*
import dev.carcara.kotlinx.locale.country.cldr.*
import dev.carcara.kotlinx.locale.currency.*
import dev.carcara.kotlinx.locale.currency.cldr.*
Country.forAlpha3("BRA") // Country.BR
Country.BR.displayName(Locale.forLanguageTag("fr")) // Brésil
Country.BR.currency // Currency.BRL
val price = CurrencyAmount(Currency.EUR, 123456) // 1234.56 in minor units
price.format(Locale.forLanguageTag("de")) // 1.234,56 €
price.format(Locale.forLanguageTag("en")) // €1,234.56Every function, parameter and default is listed in API.md.
Dates and times:
FULL, LONG, MEDIUM, SHORT) for dates,
times and date-times across all 1121 CLDR locales.date.format("yMMMd", ptBR) is "27 de jul. de 2026" and the same call in ja
is "2026年7月27日". The chosen pattern is available on its own, so it can drive
kotlinx-datetime's DateTimeFormat. Opt in through a separate artifact.июля inside a sentence
and the nominative июль on a calendar header, and Croatian writes its
stand-alone narrow months as numbers.3 days ago, včera, za 10 dní, with the plural rules
that pick among a language's forms.Pacific Standard Time, PT, the localized GMT format, and
the exemplar cities behind Los Angeles Time.Countries:
Numbers:
12,5 % with a
no-break space, Turkish reads %12,5, and 1200 compacts to 1.2K.1st, 1., 1º.Languages:
Currencies:
$1,234.56, 1.234,56 €,
₹1,23,456.78, ١٬٢٣٤٫٥٦ ج.م., with accounting and cash variants. CHF
cash rounds to 0.05.200 Ft becomes 20000,
because HUF prints without its two ISO decimals.Locales:
Locale type that parses BCP 47 tags and POSIX identifiers, with CLDR
fallback. pt-XX falls back to pt, and an unknown language to CLDR root.Locale.current reads the system locale. This is the project's single
expect/actual: one function per platform returns a raw tag, and everything
else runs in commonMain.Serialization:
Locale, Country, Currency and
CurrencyAmount, in artifacts of their own. Nothing else in the library
depends on them, so a build that serializes none of these types carries no
serialization runtime.The artifacts are not on Maven Central yet. Until they are, clone this repository and publish to your local Maven repository:
./gradlew publishToMavenLocalThen add mavenLocal() to the consuming build and depend on 0.1.0-SNAPSHOT.
The coordinates below are the ones the artifacts will carry when they ship, so
nothing but the repository line changes later.
Everything published, ready to paste into gradle/libs.versions.toml:
[versions]
kotlinx-locale = "0.1.0-SNAPSHOT"
[libraries]
# The Locale type and the LocaleDataSource contract. Everything depends on this.
locale-core = { module = "dev.carcara:kotlinx-locale-core", version.ref = "kotlinx-locale" }
# The generated locale catalog: PT.BR instead of "pt-BR". Optional.
locale-types = { module = "dev.carcara:kotlinx-locale-types", version.ref = "kotlinx-locale" }
# What the host can say about locales before any domain is involved.
locale-platform = { module = "dev.carcara:kotlinx-locale-platform", version.ref = "kotlinx-locale" }
# kotlinx.serialization strategies for Locale. Optional, and depended on by nothing else.
locale-serialization = { module = "dev.carcara:kotlinx-locale-serialization", version.ref = "kotlinx-locale" }
# Country
locale-country-types = { module = "dev.carcara:kotlinx-locale-country-types", version.ref = "kotlinx-locale" }
locale-country-core = { module = "dev.carcara:kotlinx-locale-country-core", version.ref = "kotlinx-locale" }
locale-country-cldr-runtime = { module = "dev.carcara:kotlinx-locale-country-cldr-runtime", version.ref = "kotlinx-locale" }
locale-country-cldr-full = { module = "dev.carcara:kotlinx-locale-country-cldr-full", version.ref = "kotlinx-locale" }
locale-country-platform = { module = "dev.carcara:kotlinx-locale-country-platform", version.ref = "kotlinx-locale" }
locale-country-serialization = { module = "dev.carcara:kotlinx-locale-country-serialization", version.ref = "kotlinx-locale" }
# Language, script and region names
locale-language-core = { module = "dev.carcara:kotlinx-locale-language-core", version.ref = "kotlinx-locale" }
locale-language-cldr-runtime = { module = "dev.carcara:kotlinx-locale-language-cldr-runtime", version.ref = "kotlinx-locale" }
locale-language-cldr-full = { module = "dev.carcara:kotlinx-locale-language-cldr-full", version.ref = "kotlinx-locale" }
# Numbers, plurals and ordinals
locale-number-core = { module = "dev.carcara:kotlinx-locale-number-core", version.ref = "kotlinx-locale" }
locale-number-cldr-runtime = { module = "dev.carcara:kotlinx-locale-number-cldr-runtime", version.ref = "kotlinx-locale" }
locale-number-cldr-full = { module = "dev.carcara:kotlinx-locale-number-cldr-full", version.ref = "kotlinx-locale" }
# Currency
locale-currency-types = { module = "dev.carcara:kotlinx-locale-currency-types", version.ref = "kotlinx-locale" }
locale-currency-core = { module = "dev.carcara:kotlinx-locale-currency-core", version.ref = "kotlinx-locale" }
locale-currency-cldr-runtime = { module = "dev.carcara:kotlinx-locale-currency-cldr-runtime", version.ref = "kotlinx-locale" }
locale-currency-cldr-full = { module = "dev.carcara:kotlinx-locale-currency-cldr-full", version.ref = "kotlinx-locale" }
# Currency names that agree with a count, on top of -cldr-full. Opt in.
locale-currency-cldr-plurals = { module = "dev.carcara:kotlinx-locale-currency-cldr-plurals", version.ref = "kotlinx-locale" }
locale-currency-platform = { module = "dev.carcara:kotlinx-locale-currency-platform", version.ref = "kotlinx-locale" }
locale-currency-serialization = { module = "dev.carcara:kotlinx-locale-currency-serialization", version.ref = "kotlinx-locale" }
# Date and time
locale-datetime-core = { module = "dev.carcara:kotlinx-locale-datetime-core", version.ref = "kotlinx-locale" }
locale-datetime-cldr-runtime = { module = "dev.carcara:kotlinx-locale-datetime-cldr-runtime", version.ref = "kotlinx-locale" }
locale-datetime-cldr-full = { module = "dev.carcara:kotlinx-locale-datetime-cldr-full", version.ref = "kotlinx-locale" }
# Skeleton formatting, on top of -cldr-full. Opt in.
locale-datetime-cldr-skeletons = { module = "dev.carcara:kotlinx-locale-datetime-cldr-skeletons", version.ref = "kotlinx-locale" }
# Relative wording, on top of -cldr-runtime rather than -cldr-full. Opt in.
locale-datetime-cldr-relative = { module = "dev.carcara:kotlinx-locale-datetime-cldr-relative", version.ref = "kotlinx-locale" }
# Duration wording, on the same footing as the relative tables. Opt in.
locale-datetime-cldr-durations = { module = "dev.carcara:kotlinx-locale-datetime-cldr-durations", version.ref = "kotlinx-locale" }
# Date and time ranges, on top of the skeletons. Opt in.
locale-datetime-cldr-intervals = { module = "dev.carcara:kotlinx-locale-datetime-cldr-intervals", version.ref = "kotlinx-locale" }
locale-datetime-platform = { module = "dev.carcara:kotlinx-locale-datetime-platform", version.ref = "kotlinx-locale" }
# Time zone names
locale-timezone-core = { module = "dev.carcara:kotlinx-locale-timezone-core", version.ref = "kotlinx-locale" }
locale-timezone-cldr-runtime = { module = "dev.carcara:kotlinx-locale-timezone-cldr-runtime", version.ref = "kotlinx-locale" }
locale-timezone-cldr-full = { module = "dev.carcara:kotlinx-locale-timezone-cldr-full", version.ref = "kotlinx-locale" }
# The exemplar cities, on top of -cldr-full. Opt in.
locale-timezone-cldr-cities = { module = "dev.carcara:kotlinx-locale-timezone-cldr-cities", version.ref = "kotlinx-locale" }
# Phone numbers. The data is Google's libphonenumber rather than CLDR.
locale-personname-core = { module = "dev.carcara:kotlinx-locale-personname-core", version.ref = "kotlinx-locale" }
locale-personname-cldr-runtime = { module = "dev.carcara:kotlinx-locale-personname-cldr-runtime", version.ref = "kotlinx-locale" }
locale-personname-cldr-full = { module = "dev.carcara:kotlinx-locale-personname-cldr-full", version.ref = "kotlinx-locale" }
locale-phone-core = { module = "dev.carcara:kotlinx-locale-phone-core", version.ref = "kotlinx-locale" }
locale-phone-metadata-runtime = { module = "dev.carcara:kotlinx-locale-phone-metadata-runtime", version.ref = "kotlinx-locale" }
locale-phone-metadata-full = { module = "dev.carcara:kotlinx-locale-phone-metadata-full", version.ref = "kotlinx-locale" }
locale-phone-serialization = { module = "dev.carcara:kotlinx-locale-phone-serialization", version.ref = "kotlinx-locale" }
[bundles]
# Bundled CLDR data: the normal choice.
locale-country-cldr = ["locale-country-types", "locale-country-core", "locale-country-cldr-full"]
locale-currency-cldr = ["locale-currency-types", "locale-currency-core", "locale-currency-cldr-full"]
# The same, plus the names that agree with a count: "2 US dollars".
locale-currency-plurals = ["locale-currency-types", "locale-currency-core", "locale-currency-cldr-plurals"]
locale-datetime-cldr = ["locale-datetime-core", "locale-datetime-cldr-full"]
# The same, plus skeleton formatting.
locale-datetime-skeletons = ["locale-datetime-core", "locale-datetime-cldr-full", "locale-datetime-cldr-skeletons"]
# Relative wording, which needs no date patterns.
locale-datetime-relative = ["locale-datetime-core", "locale-datetime-cldr-relative"]
# Duration wording, which needs none either.
locale-datetime-durations = ["locale-datetime-core", "locale-datetime-cldr-durations"]
# Ranges, which pull the skeletons in because an interval is a split of one.
locale-datetime-intervals = ["locale-datetime-core", "locale-datetime-cldr-full", "locale-datetime-cldr-skeletons", "locale-datetime-cldr-intervals"]
locale-personname-cldr = ["locale-personname-core", "locale-personname-cldr-full"]
locale-language-cldr = ["locale-language-core", "locale-language-cldr-full"]
locale-number-cldr = ["locale-number-core", "locale-number-cldr-full"]
# Zone names. The second adds the exemplar cities, which is the larger half.
locale-timezone-cldr = ["locale-timezone-core", "locale-timezone-cldr-full"]
locale-timezone-cities = ["locale-timezone-core", "locale-timezone-cldr-cities"]
locale-phone = ["locale-phone-core", "locale-phone-metadata-full"]
# The host's data instead, shipping no tables.
locale-country-host = ["locale-country-types", "locale-country-core", "locale-country-platform"]
locale-currency-host = ["locale-currency-types", "locale-currency-core", "locale-currency-platform"]
locale-datetime-host = ["locale-datetime-core", "locale-datetime-platform"]
# For a build that generates its own narrowed data with the Gradle plugin.
locale-country-narrowed = ["locale-country-types", "locale-country-core", "locale-country-cldr-runtime"]
locale-currency-narrowed = ["locale-currency-types", "locale-currency-core", "locale-currency-cldr-runtime"]
locale-datetime-narrowed = ["locale-datetime-core", "locale-datetime-cldr-runtime"]
locale-language-narrowed = ["locale-language-core", "locale-language-cldr-runtime"]
locale-number-narrowed = ["locale-number-core", "locale-number-cldr-runtime"]
locale-personname-narrowed = ["locale-personname-core", "locale-personname-cldr-runtime"]
locale-timezone-narrowed = ["locale-timezone-core", "locale-timezone-cldr-runtime"]
# There is no locale-phone-narrowed: the phone metadata is keyed by territory
# rather than by locale, so declaring three locales narrows nothing about it.
# Take locale-phone directly, at 76 KB for every territory in the world.
[plugins]
# Generates a data set narrowed to the locales a build declares.
kotlinx-locale = { id = "dev.carcara.kotlinx-locale", version.ref = "kotlinx-locale" }repositories {
mavenLocal()
mavenCentral()
}
kotlin {
sourceSets.commonMain.dependencies {
implementation(libs.bundles.locale.datetime.cldr)
implementation(libs.bundles.locale.country.cldr)
implementation(libs.bundles.locale.currency.cldr)
}
}Without a version catalog, the same thing written out:
kotlin {
sourceSets.commonMain.dependencies {
implementation("dev.carcara:kotlinx-locale-datetime-core:0.1.0-SNAPSHOT")
implementation("dev.carcara:kotlinx-locale-datetime-cldr-full:0.1.0-SNAPSHOT")
}
}Each layer depends on the ones below it with api, so -cldr-full on its own
resolves the whole set. The bundles list all three because a dependency block
that names what it uses is easier to audit than one that relies on transitives.
There is no umbrella artifact. An artifact whose only job is to pull three others is a second place for the dependency set to be wrong, and a catalog bundle does the same job in the build where the versions already live.
Only the JVM variant, since Maven cannot resolve Kotlin Multiplatform metadata:
<dependency>
<groupId>dev.carcara</groupId>
<artifactId>kotlinx-locale-datetime-cldr-full-jvm</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>Built against Kotlin 2.4.0. The datetime modules expose kotlinx-datetime 0.8.0
as an api dependency, so LocalDate in your code and LocalDate in a format
call are the same type. The -serialization modules expose
kotlinx-serialization-core 1.11.0 the same way, for the same reason: a
KSerializer<Country> you can name is one your own compile classpath has to
know about.
They need the runtime and nothing else. Their serializers are written by hand,
so the serialization compiler plugin is not applied to a single published source
file here. Apply it in your own build if you write @Serializable classes, as
you already would.
Artifacts are named kotlinx-locale[-<domain>]-<layer>. Every domain has the
same layers, sorted along one axis: who supplies the data. The translated text,
which is the part that weighs anything, lives in exactly one of them.
kotlinx-locale-currency-types generated enums
kotlinx-locale-currency-core the contract
kotlinx-locale-currency-cldr-runtime the engine, no data
kotlinx-locale-currency-cldr-full the engine plus 1121 locales
kotlinx-locale-currency-cldr-plurals the same, plus the count-keyed names
kotlinx-locale-currency-platform the host supplies it
Nothing at a call site says which layer answered, which is the point.
Country.BR.alpha3 reads from -types, Country.forAlpha3("BRA") from -core
and Country.BR.displayName(locale) from -cldr-full, and all three are
written the same way. Generated types carry only their per-entry data;
everything else about them is an extension, so a declaration can move between
layers without touching a call site.
The -cldr-full row is one of three ways to get the tables, not the only one.
The other two are the Gradle plugin, which generates the same tables and the
same entry points for the locales a build declares, and the -platform layer,
which answers from the host and ships nothing. The -core and -cldr-runtime
layers are common to the first two, so
Shipping only the locales you use is a
dependency change rather than a different API. API.md names the
plugin flag beside the artifact for every domain that has one.
| Module | What it contains |
|---|---|
kotlinx-locale-core |
The Locale type: tag parsing, normalization, system locale detection, the fallback chain, and the LocaleDataSource contract every data source answers. Depends on nothing. |
kotlinx-locale-platform |
What the host can say about locales before any domain is involved: whether it exposes locale data, and which locales it enumerates. |
kotlinx-locale-types |
The generated locale catalog: one enum per language, so PT.BR names a locale the compiler checks instead of a string that fails at runtime. Optional, and generatable: a build that ships a handful of locales can write its own with catalog = true. |
kotlinx-locale-serialization |
LocaleTagSerializer, which writes a Locale as its BCP 47 tag and reads one as leniently as Locale.forLanguageTag does. |
kotlinx-locale-country-types |
The Country enum: 249 ISO 3166-1 entries carrying their alpha-3 and numeric codes. Generated, and nothing else. |
kotlinx-locale-country-core |
alpha2, the for* lookups, and CountryNameSource with the total operations and the fallback composer over it. |
kotlinx-locale-country-cldr-runtime |
The country-name lookup over CLDR-shaped name records, and none of the records. The table is a constructor argument, which is what a narrowed build binds its own to. |
kotlinx-locale-country-cldr-full |
-cldr-runtime plus the CLDR name tables for all 1121 locales: CldrCountry and Country.displayName. |
kotlinx-locale-country-platform |
PlatformCountry: country names from java.util.Locale, Intl.DisplayNames or NSLocale. Ships no tables. |
kotlinx-locale-country-serialization |
One Country serializer per ISO 3166-1 code (alpha-2, alpha-3, numeric), plus a lenient reader that takes all three and writes alpha-2. |
kotlinx-locale-language-core |
LanguageNameSource and the locale display name algorithm of UTS #35 Part 2: how a language name and its unconsumed subtags compose into Serbian (Cyrillic). |
kotlinx-locale-language-cldr-runtime |
The language, script and region name lookup over CLDR-shaped records it does not carry. |
kotlinx-locale-language-cldr-full |
-cldr-runtime plus the name tables: CldrLanguage, Locale.displayName and Locale.nativeDisplayName. The largest table in the library, which is the strongest argument for the Gradle plugin. |
kotlinx-locale-number-core |
Decimal, NumberSymbols, PluralCategory, SignDisplay and the number, plural and ordinal contracts. Its own README records where each part of the behaviour is defined, because CLDR settles the data and not the option names. |
kotlinx-locale-number-cldr-runtime |
The CLDR pattern engine, the compact algorithm, the plural rule evaluator and the ordinal rule evaluator. The currency domain formats through this one rather than through a copy. |
kotlinx-locale-number-cldr-full |
-cldr-runtime plus the symbol, pattern, compact, plural and ordinal tables: CldrNumber, numberFormat, numberFormatPercent, numberOrdinal, numberSymbols and pluralCategory. |
kotlinx-locale-currency-types |
The Currency enum (both ISO 4217 lists, ISO minor units, CLDR fraction and cash-rounding behavior, tender windows) and the country-to-currency map. |
kotlinx-locale-currency-core |
code, minorUnitDigits, the ISO/CLDR scale conversions, the for* lookups, CurrencyAmount and its arithmetic, and the CurrencyNameSource and CurrencyFormatSource contracts. |
kotlinx-locale-currency-cldr-runtime |
The symbol and name lookup plus the pattern-based number formatter and parser, over CLDR-shaped records it does not carry. |
kotlinx-locale-currency-cldr-full |
-cldr-runtime plus the CLDR symbol, name and number tables for all 1121 locales: CldrCurrency, Currency.symbol, Currency.displayName and CurrencyAmount.format. |
kotlinx-locale-currency-cldr-plurals |
-cldr-full plus CLDR's count-keyed currency names and the patterns that join one to a number: CldrCurrencyPlurals, CurrencyAmount.formatPluralName and Currency.pluralName. |
kotlinx-locale-currency-platform |
PlatformCurrency: symbols, names and number formatting from NumberFormat, Intl.NumberFormat or NSNumberFormatter. Ships no tables. |
kotlinx-locale-currency-serialization |
The Currency serializers (alphabetic code, numeric code, lenient) and the three CurrencyAmount forms. Locale-independent throughout, and so free of CLDR. |
kotlinx-locale-datetime-core |
FormatStyle, TextStyle and the DateTimeFormatSource contract. The only module that depends on kotlinx-datetime. |
kotlinx-locale-datetime-cldr-runtime |
The pattern parser and formatter plus the record lookup, over CLDR-shaped records it does not carry. |
kotlinx-locale-datetime-cldr-full |
-cldr-runtime plus the CLDR pattern data for all 1121 locales: CldrDateTime, LocalDate.format and friends. |
kotlinx-locale-datetime-cldr-skeletons |
-cldr-full plus the skeleton tables: CldrDateTimeSkeletons and date.format("yMMMd", locale), where you name the fields and the locale decides their order. Opt in, at around 58 KB gzipped on top of -cldr-full. |
kotlinx-locale-datetime-cldr-intervals |
CldrDateTimeIntervals and intervalFormat: Jul 18 – 22, 2026, with the parts both ends share written once. Builds on -cldr-skeletons, since a range is a split of the pattern the matcher picks, and adds around 28 KB gzipped over it. |
kotlinx-locale-personname-core |
PersonName and the option enums, plus PersonNameSource. No data. |
kotlinx-locale-personname-cldr-runtime |
Pattern selection, field modifiers and the empty-field cleanup. Carries no records. |
kotlinx-locale-personname-cldr-full |
CldrPersonName, personNameFormat and personNameOrder: a name written the way a locale writes one, and its initials. |
kotlinx-locale-datetime-cldr-relative |
CldrRelativeTime and relativeTimeFormat: 3 days ago and včera, with the plural rules that pick among a language's forms. Its own artifact because it needs no date patterns. |
kotlinx-locale-datetime-cldr-durations |
CldrDurationUnits and durationFormat: 2 hours, 2 hr, 2h, across fourteen time units and three widths. Its own artifact for the same reason as the relative tables, and around 117 KB gzipped. Not the same thing as durationPattern, which gives h:mm and ships with -cldr-full. |
kotlinx-locale-datetime-platform |
PlatformDateTime: the four lengths and the calendar names from DateTimeFormatter, Intl.DateTimeFormat or NSDateFormatter. Ships no tables. |
kotlinx-locale-timezone-core |
TimeZoneNameSource and TimeZoneNameStyle: the forms UTS #35 Part 4 defines for naming a zone. |
kotlinx-locale-timezone-cldr-runtime |
The localized GMT format, metazone resolution and the naming ladder, over records it does not carry. |
kotlinx-locale-timezone-cldr-full |
-cldr-runtime plus the format and name tables: CldrTimeZone, TimeZone.displayName and UtcOffset.displayName. |
kotlinx-locale-timezone-cldr-cities |
-cldr-full plus the exemplar cities, for the generic location format. Opt in: this is the largest zone table, and without it the format falls back to the identifier's own last part, which is what the spec prescribes. |
kotlinx-locale-phone-core |
PhoneNumber, PhoneNumberType, PhoneNumberFormat and PhoneNumberSource. Keyed by country rather than by locale, because a number is valid or not whoever is reading it. |
kotlinx-locale-phone-metadata-runtime |
The parser, the validator, the formatters, the as-you-type formatter, and the bounded pattern matcher they all run on. |
kotlinx-locale-phone-metadata-full |
-metadata-runtime plus every territory libphonenumber describes: PhoneNumbers, phoneNumberOrNull and Country.asYouType. |
kotlinx-locale-phone-serialization |
One serializer per written form, a lenient one that reads all four, and a metadata-free one over the parts. No default: the forms carry different amounts of information. |
kotlinx-locale-codegen-emitters |
The emitters and the bundle reader: the half of code generation a build can run. Parses no XML and clones nothing, so it is safe on a build classpath. |
kotlinx-locale-codegen-data |
CLDR resolved into one compact record per locale, versioned by the release it came from. What a build reads instead of cloning CLDR. |
kotlinx-locale-gradle-plugin |
The dev.carcara.kotlinx-locale plugin, which generates a data set narrowed to the locales a build declares. |
Anything named kotlinx-locale-codegen-* runs at build time and never belongs
on an application classpath.
Each published module lives in a directory of the same name, so a listing of
this repository's root reads like a listing on Maven Central. A directory
without the prefix publishes nothing: conformance-test-suite/ holds the ICU
fixtures and the assertions this repo's own test source sets run, codegen/ is
the extraction half of code generation that clones CLDR and ICU, tools/ holds
the Kotlin/JS size probes, and build-logic/ is the included build with the
convention plugins.
No artifact name is a prefix of another at a hyphen boundary, because Kotlin
Multiplatform already owns that suffix space: every module publishes one
artifact per target, so -jvm and -iosarm64 sit beside the bare coordinate on
Maven Central. settings.gradle.kts enforces it at configuration time.
Where this behaviour is defined is worth saying, because it is two places. The
vocabulary is normative: the field letters of yMMMd are UTS #35's Date Field
Symbol Table and availableFormats is an LDML element. The matching algorithm
is not. How a missing field is weighed against a wrong width, and which of M
and L a locale's own pattern imposes, come from ICU's
DateTimePatternGenerator, which is why :codegen checks out ICU's source and
extracts goldens from it. The number domain has the same split for the same
reason, and kotlinx-locale-number-core/README.md sets out the argument for
following ICU where LDML is silent.
FormatStyle offers four fixed lengths. A skeleton instead names the fields you
want, in no particular order, and the locale decides how to arrange them. This is
what DateFormat.getBestDateTimePattern gives an Android developer and
setLocalizedDateFormatFromTemplate an iOS one.
import dev.carcara.kotlinx.locale.datetime.cldr.skeletons.*
date.format("yMMMd", Locale.forLanguageTag("pt-BR")) // "27 de jul. de 2026"
date.format("yMMMd", Locale.forLanguageTag("ja")) // "2026年7月27日"
date.format("MMMEd", Locale.forLanguageTag("en")) // "Mon, Jul 27"The letters are CLDR's: y year, M month, d day, E weekday, Q quarter,
h and H hour, m minute, s second, G era. Repeating one asks for a
width, so MMM is an abbreviated month name and MMMM a full one. j asks for
whichever hour the locale prefers together with the day period that goes with
it, which is usually what you want:
time.format("jm", Locale.forLanguageTag("en")) // "3:05 PM", with U+202F before PM
time.format("jm", Locale.forLanguageTag("en-GB")) // "15:05"The pattern is available on its own, not only the formatted string:
skeletonPatternOrNull("yMMMd", Locale.forLanguageTag("pt-BR")) // "d 'de' MMM 'de' y"
skeletonPatternOrNull("yMd", Locale.forLanguageTag("pt-BR")) // "dd/MM/y"A numeric pattern composes with kotlinx-datetime today, which buys locale-aware parsing off the same table:
LocalDate.Format { byUnicodePattern(skeletonPatternOrNull("yMd", ptBR)!!) }A pattern naming a month or a weekday does not. byUnicodePattern rejects MMM
and EEE with "the directive is locale-dependent, but locales are not supported
in Kotlin", which is the gap this library fills on the formatting side and does
not yet fill on the parsing side. Formatting is one-way for anything with a name
in it.
Time zones, week numbers and fractional seconds are out of scope, because a
LocalDate carries no zone and week numbering needs data this library does not
ship, so a skeleton naming one of those is refused rather than answered a field
short.
The matcher is the algorithm from UTS #35 written in common Kotlin. Nothing delegates to ICU at runtime; the agreement between the two is a test, held to patterns generated from ICU4J across 859 locales and 109 skeletons on all eight targets, plus CLDR's own datetime cases.
The tables live in their own artifact so that a consumer of ordinary date
formatting does not pay for skeletons. Measured by the probes in tools/, in
gzipped bundle size:
| take | size | added |
|---|---|---|
-cldr-full |
127.3 KB | |
plus -cldr-skeletons
|
185.8 KB | 58.5 KB |
plus -cldr-intervals
|
213.3 KB | 27.5 KB |
Each layer builds on the one above it rather than repeating its tables, so
asking for intervals brings the skeletons and the patterns with it. That is not
a packaging accident: formatting a range means picking a pattern for the
requested skeleton and then rendering both halves, which needs the month names,
weekday names and digits that -cldr-full carries.
The algorithms sit in -cldr-runtime instead, for the same reason the pattern
formatter already does. A build narrowed through the Gradle plugin depends on
-cldr-runtime alone and takes none of these three artifacts: it generates its
own tables, and each feature declares the whole closure it needs, so
datetime { intervals = true } generates the pattern, skeleton and interval
tables together. The numbers above are the cost of the bundled path, not of the
narrowed one.
Every domain has a -platform layer that answers from the host rather than from
bundled tables: java.util.Locale and java.time on JVM and Android, Intl on
JS and Wasm/JS, Foundation on Apple. Nothing ships, and in exchange the answers
are whatever the device says.
import dev.carcara.kotlinx.locale.datetime.platform.*
date.format(FormatStyle.LONG, Locale.forLanguageTag("pt-BR"))The same call as the CLDR version with a different import, which is what the package split buys.
What it saves, measured by the Kotlin/JS probes in tools/ making identical
calls against each layer:
| domain | platform | CLDR | saved |
|---|---|---|---|
| datetime | 35.3 KB | 127.3 KB | 92.0 KB |
| country | 20.2 KB | 376.4 KB | 356.2 KB |
| currency | 25.1 KB | 442.8 KB | 417.7 KB |
| all three | 49.1 KB | 911.0 KB | 861.9 KB |
Gzipped over the minified bundle. Datetime saves the least because
kotlinx-datetime sits in both numbers and only the formatting moved.
docs/size.md has the full table and is regenerated from the
build rather than typed.
Read that CLDR column as the price of all 1121 locales, not the price of CLDR.
Most products ship a language picker with a handful of entries in it, and the
Gradle plugin generates the data for exactly those, against the same
-cldr-runtime engine the full artifact uses. The three-locale build in
samples/narrowed/ generates 205 KB of tables where the shipped modules it
replaces hold 6699 KB, roughly a thirty-third of the data, and formats
identically for the locales it kept because it is running the same code over a
smaller table.
So the choice is not the two columns above. It is three:
| what ships | answers | when it fits | |
|---|---|---|---|
-platform |
nothing | whatever the host says, with the gaps below | you do not know the locale set, or you want it to track the device |
-cldr-full |
all 1121 locales | the same on every target | you need arbitrary locales at runtime |
plugin plus -cldr-runtime
|
only the locales you named | the same on every target | you know the set at build time, which is most products |
Shipping only the locales you use covers how to set the third one up.
Those 205 KB and 6699 KB are Kotlin source, counted from the sample and from the
internal/data trees it replaces. The size probes in tools/ do not cover a
narrowed build, so there is no gzipped bundle figure for it to sit beside the
table above.
Skeleton formatting is CLDR only, and that is a decision rather than a gap. The
hosts will format from a template, but none of them hands back the pattern it
chose, and half of what makes a skeleton useful is reusing that pattern for
parsing. A build that wants skeletons takes
kotlinx-locale-datetime-cldr-skeletons.
Two things to know before choosing it.
Platform sources are partial, for two different reasons.
Locale data is not wired up on Linux, Windows, Android Native or Wasm-WASI yet, so every lookup misses on those four. That is a gap rather than a verdict on the platforms, and contributions are welcome.
The rest is the shape of the host APIs. Cash rounding is not a platform concept
anywhere. Accounting formats exist on Intl and Foundation but not in
java.text. Currency parsing exists only where it is exact, which is JVM and
Android.
Either way a miss is the signal the Fallback* composers read:
val dates = FallbackDateTimeFormats(primary = PlatformDateTime, fallback = CldrDateTime)Composition does not round trip across sources. Foundation writes ¥ for JPY in
ja where CLDR writes the fullwidth ¥, so a string one produced is not
necessarily one the other parses. Formatting with the platform and parsing with
CLDR is not something the library promises.
Three artifacts carry kotlinx.serialization strategies, one per domain, and nothing else in the library depends on them:
import dev.carcara.kotlinx.locale.serialization.*
import dev.carcara.kotlinx.locale.country.serialization.*
import dev.carcara.kotlinx.locale.currency.serialization.*
@Serializable
data class Order(
@Serializable(with = LocaleTagSerializer::class) val locale: Locale,
@Serializable(with = CountryAlpha3Serializer::class) val shipTo: Country,
@Serializable(with = CurrencyAmountMinorUnitsSerializer::class) val total: CurrencyAmount,
)
val order = Order(
locale = Locale.forLanguageTag("pt-BR"),
shipTo = Country.BR,
total = CurrencyAmount(Currency.BRL, 1234_56),
)
Json.encodeToString(order)
// {"locale":"pt-BR","shipTo":"BRA","total":{"currency":"BRL","minorUnits":123456}}Country and Currency need none of this to serialize at all. Their entry
names are the alpha-2 and the ISO 4217 alphabetic code, and the plugin writes an
enum as its entry name, so an unannotated property already produces "BR" and
"BRL". What the named serializers add is a contract: CountryAlpha3Serializer
on a field says alpha-3 out loud, and it fails on the day something sends
"BR" instead of quietly accepting it.
Every serializer says which representation it is, and there is no unnamed one to fall into by accident:
| Type | Serializer | JSON |
|---|---|---|
Locale |
LocaleTagSerializer |
"pt-BR" |
Country |
CountryAlpha2Serializer |
"US" |
CountryAlpha3Serializer |
"USA" |
|
CountryNumericCodeSerializer |
840 |
|
CountryLenientCodeSerializer |
reads all three, writes "US"
|
|
Currency |
CurrencyCodeSerializer |
"USD" |
CurrencyNumericCodeSerializer |
840 |
|
CurrencyLenientCodeSerializer |
reads both, writes "USD"
|
|
CurrencyAmount |
CurrencyAmountMinorUnitsSerializer |
{"currency":"USD","minorUnits":123456} |
CurrencyAmountDecimalSerializer |
{"currency":"USD","amount":"1234.56"} |
|
CurrencyAmountCodeAndDecimalSerializer |
"USD 1234.56" |
The two object forms of an amount differ in where the scale lives. minorUnits
is exact and needs no parsing, but 123456 is $1,234.56 only because the
Currency enum says USD has two minor units, so both ends have to agree on the
ISO data. The decimal string puts the scale in the payload, which is what a row
that outlives a release wants. The combined string is the one to reach for when
the amount has to fit a single scalar: a map key, a query parameter, a column
you would rather not split in two.
A country's three ISO code spaces do not overlap. Alpha-2 is two letters, alpha-3 is three, numeric is digits, so one reader can take any of them and still know which space it is in:
Json.decodeFromString(CountryLenientCodeSerializer, "\"US\"") // Country.US
Json.decodeFromString(CountryLenientCodeSerializer, "\"USA\"") // Country.US
Json.decodeFromString(CountryLenientCodeSerializer, "\"840\"") // Country.US
Json.decodeFromString(CountryLenientCodeSerializer, "\"004\"") // Country.AFIt writes alpha-2 whichever one it read, so the second time a row is written it is canonical. That is the migration: point the field at this serializer, and the old spellings drain out as rows are touched.
One limit is worth knowing before you rely on it. The lenient readers take the
numeric code as a string, "840". A JSON number 840 is a different token,
and a Decoder has to commit to decodeString or decodeInt before it can see
which one is coming. The format-agnostic API these are written against offers
no way to peek. So a bare number needs one of two things:
// Tell the format to be forgiving; it hands the unquoted token over as text.
Json { isLenient = true }.decodeFromString(CountryLenientCodeSerializer, "840")
// Or, better, name the serializer for the type the field actually holds.
Json.decodeFromString(CountryNumericCodeSerializer, "840")The second is the honest answer when the field is genuinely a number. The declaration then says what the field holds, and read time has nothing left to resolve.
None of the CurrencyAmount serializers touches Locale, and
kotlinx-locale-currency-serialization depends on no CLDR data. The two string
forms of an amount do different jobs. toDecimalString writes ASCII digits and
a . and nothing else; format(locale) writes what a person expects to read,
which on some locales means grouping separators, a symbol, Arabic-Indic digits
and a narrow no-break space.
Only the first can be a wire format. The second cannot be read back without
knowing which locale wrote it, and CLDR moves separators between releases, so an
amount stored under one release could come back a different number under the
next. "USD 1,234.56" is a SerializationException here, deliberately.
Most applications need a handful of locales, not 1121. The Gradle plugin generates the data set for the ones a build declares:
plugins {
id("dev.carcara.kotlinx-locale") version "0.1.0-SNAPSHOT"
}
kotlinxLocale {
locales(PT.BR, EN.US, JA)
fallback(EN.US)
packageName = "com.example.locale"
country { names = true }
currency { names = true; formats = true }
datetime { patterns = true; skeletons = true }
}skeletons implies patterns: matching a skeleton scores against the locale's
standard date and time patterns, and rendering the winner needs its month and
weekday names, so the two tables travel together.
The dependency block then takes -core, -types and -cldr-runtime and leaves
out -cldr-full, because the records come from the generator instead. Call
sites do not change: the generated source implements the same interfaces and
carries the same extensions, so Country.BR.displayName(locale) still reads the
same and only the import moves. samples/narrowed/ is a working build that does
this, at roughly a thirtieth of the data
for the three locales it declares.
fallback is required, and required to be one of the generated locales. Ask a
three-locale build for de and it answers in the fallback rather than returning
nothing. That matters most for dates: a country or a currency can degrade to its
ISO code, but a date would surface as an ISO 8601 timestamp in the middle of a
translated screen.
Every domain that ships a -cldr-full artifact has flags of its own: country,
currency, datetime, language, number, time zones and person names. Phone metadata
does not, because it is keyed by territory rather than by locale, so a locale set
narrows nothing about it. API.md lists every flag next to
what it generates and the -cldr-runtime artifact it needs.
The flags above narrow locale data. The entry sets are separate, and shipped
whole by default: kotlinx-locale-types carries 322 enums for all 1121 CLDR
locales, Country carries 249 entries and Currency every ISO 4217 code. A
build that uses eight locales still gets all of them.
kotlinxLocale {
locales(PT.BR, EN.US)
fallback(EN.US)
catalog = true
country { entries(Country.BR, Country.US); names = true }
currency { entries(Currency.BRL, Currency.USD); names = true }
}catalog generates the locale catalog for the locales already declared, into
<packageName>.catalog, so naming a locale this build does not ship stops
compiling. It costs nothing else: nothing in the library extends a catalog enum,
so a build that generates its own leaves kotlinx-locale-types out of the
dependency block and that is the whole change.
entries is a real trade rather than a free saving, and worth reading twice
before turning on. Country and Currency keep the library's own package,
because kotlinx-locale-country-core declares Country.alpha2 and
Country.forAlpha2 on that exact name, so the plugin drops the shipped artifact
from the resolved classpath and the generated enum takes its place. You write
nothing in the dependency block for that. What you give up is representation: an
unlisted locale answers in the fallback, but there is no fallback for an unlisted
country. Country.forAlpha2OrNull("DE") returns null once DE is out of the enum,
so a build that parses ISO codes out of a payment API or a partner feed wants the
whole set.
The names follow the entries. Ask for two countries and the territory tables come out holding two names per locale rather than 249, which across every locale is the larger half of what narrowing the enum is for. API.md has the three flags next to what each replaces.
The build compiles and tests twenty-five targets. A release publishes six of them.
| Group | Targets |
|---|---|
| JVM |
jvm (toolchain 21), Android (compileSdk 36, minSdk 21) |
| Web | js |
| Apple |
iosArm64, iosSimulatorArm64, iosX64
|
js and wasmJs run in a browser as readily as under Node. Neither touches the
DOM or a Node built-in: Locale.current and the -platform modules go through
Intl, which every browser and every full-ICU Node build provides. The test
tasks use Node because it starts faster than a headless browser, which is a
choice about the test runner rather than about where the artifact works.
The other nineteen are missing from Maven Central rather than unsupported. Each one compiles and runs its tests on every push. They do not ship yet because Maven Central meters how many files an organization publishes each month, and twenty-five targets across forty-two multiplatform modules is twenty-six publications each, several times what that allows. The list follows the Kotlin/Native tiers and matches what kotlinx-datetime publishes.
| Group | Targets |
|---|---|
| Web |
wasmJs, wasmWasi
|
| Native tier 1 | macosArm64 |
| Native tier 2 |
linuxX64, linuxArm64, watchosArm32, watchosArm64, watchosSimulatorArm64, tvosArm64, tvosSimulatorArm64
|
| Native tier 3 |
androidNativeArm32, androidNativeArm64, androidNativeX86, androidNativeX64, mingwX64, watchosDeviceArm64
|
| Deprecated in Kotlin/Native |
macosX64, watchosX64, tvosX64
|
The last row is deprecated in Kotlin/Native but still published by kotlinx-datetime (KT-78660), so dropping it from the build would strand consumers who target it.
Only Locale.current and the -platform modules behave differently across
those two lists. Everything else is target-independent.
| Platform | Source |
|---|---|
| JVM and Android | java.util.Locale.getDefault() |
| Apple platforms |
NSLocale.preferredLanguages, then NSLocale.currentLocale
|
| JS and Wasm-JS | Intl.DateTimeFormat().resolvedOptions().locale |
| Linux and Android Native |
LC_ALL, LC_TIME, LANG
|
| Windows | GetUserDefaultLocaleName |
| Wasm-WASI | nothing exposed, so Locale.current returns en
|
🟢 the module answers from that target. 🟡 the lookup misses and the documented fallback applies.
The bundled modules first, because they are the flat row: pure common Kotlin, no expect/actual, the same answer on all 25 targets.
Time zone names are the one place where that flat row meets something the
platform owns. Naming a zone is pure common Kotlin like everything else here,
because it works from the identifier and the tables. Constructing a
kotlinx.datetime.TimeZone is not: each target reads whichever copy of the IANA
time zone database it has, and Kotlin/JS under Node has no full one, so
TimeZone.of("America/Los_Angeles") throws there for an identifier every other
target accepts. That is a property of the runtime rather than of this library,
and it is why the zone tests skip where a zone cannot be built.
| Module | JVM, Android | Apple | JS, Wasm-JS | Linux, Windows, Android Native, Wasm-WASI |
|---|---|---|---|---|
kotlinx-locale-core |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-types |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-country-cldr-full |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-currency-cldr-full |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-datetime-cldr-full |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-datetime-cldr-skeletons |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-serialization |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-country-serialization |
🟢 | 🟢 | 🟢 | 🟢 |
kotlinx-locale-currency-serialization |
🟢 | 🟢 | 🟢 | 🟢 |
Locale.current is the one exception in -core. It reads a real tag everywhere
except Wasm-WASI, which exposes nothing and so returns en.
The -platform modules are where the gaps are, and they are not uniform:
| Module | Operation | JVM, Android | Apple | JS, Wasm-JS | Linux, Windows, Android Native, Wasm-WASI |
|---|---|---|---|---|---|
kotlinx-locale-platform |
isAvailable |
🟢 true
|
🟢 true
|
🟢 true
|
🟡 false
|
availableLocaleTags() |
🟢 full list | 🟢 full list | 🟡 empty | 🟡 empty | |
kotlinx-locale-country-platform |
countryNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 |
kotlinx-locale-currency-platform |
currencySymbolOrNull |
🟢 | 🟢 | 🟢 | 🟡 |
currencyNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
formatOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
formatOrNull(accounting = true) |
🟡 | 🟢 | 🟢 | 🟡 | |
formatOrNull(cash = true) |
🟡 | 🟡 | 🟡 | 🟡 | |
parseToMinorUnitsOrNull |
🟢 | 🟡 | 🟡 | 🟡 | |
kotlinx-locale-datetime-platform |
formatDateOrNull |
🟢 | 🟢 | 🟢 | 🟡 |
formatTimeOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
formatDateTimeOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
monthNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
dayOfWeekNameOrNull |
🟢 | 🟢 | 🟢 | 🟡 | |
| skeleton formatting | not offered | not offered | not offered | not offered |
A 🟡 never surfaces as a null to your code. The total extensions layered in
-core fall back: country and currency names degrade to the ISO code, and dates
to ISO 8601. What a miss costs you is the localization, which is why the
Fallback* composers exist, and why pairing a -platform module with a bundled
one turns every 🟡 above back into a real answer:
val names = FallbackCountryNames(primary = PlatformCountry, fallback = CldrCountry)
val formats = FallbackCurrencyFormats(primary = PlatformCurrency, fallback = CldrCurrency)
val dates = FallbackDateTimeFormats(primary = PlatformDateTime, fallback = CldrDateTime)Skeleton formatting is the one row with no platform column at all. It is not
part of kotlinx-locale-datetime-core, so a -platform source cannot answer it
even in principle, for the reason given above: the hosts format from a template
but will not hand back the pattern they chose.
The 🟡s come from two different places, and the difference matters if you are deciding whether to wait for one to change.
Linux, Windows (mingwX64), Android Native and Wasm-WASI have no locale data
wired up yet, so all four return null for everything and report
isAvailable == false. This is the one gap that is simply unbuilt rather than
decided. Windows and Linux in particular do have locale facilities to read, and
this library already reads a little of both for Locale.current, so extending
that to names, currencies and dates is work waiting to be done rather than a
wall. If you want one of these targets, that is a contribution worth having.
Everything else below is a property of the host APIs and will not change by trying harder.
The empty availableLocaleTags() on JS and Wasm-JS is not a gap in the runtime.
ECMA-402 offers supportedLocalesOf to filter a list you already have but
nothing to ask for the list, so a source over Intl answers every lookup while
being unable to describe its coverage. That is why isAvailable and
availableLocaleTags() are separate questions.
Cash rounding is not a platform concept anywhere. CLDR knows that CHF cash
rounds to 0.05, and no host formatter does, so cash = true misses on every
target.
Accounting formats exist on Intl (currencySign) and Foundation
(NSNumberFormatterCurrencyAccountingStyle) but not in java.text, so
accounting = true misses on JVM and Android.
Currency parsing is offered only where it is exact. JVM and Android parse
through BigDecimal. Intl has no parser at all. Foundation's
numberFromString returns an NSNumber backed by a Double, which would
quietly lose minor units on large amounts, so Apple reports a miss rather than
round-tripping money through a Double. For the same reason there is no
throwing parseFormatted in the platform package, only parseFormattedOrNull.
Finally, a host that does not know a code tends to hand the code back rather
than admit it, which java.util.Locale does. An answer equal to the requested
code is treated as a miss, because the total operation already falls back to the
code and a composing source would otherwise take the echo for an answer and
never consult its fallback.
Which standard each module implements, with a link to the primary source for every one, is in docs/standards.md. The standard is the source of truth; this library is one reading of it, and where the two disagree the standard is right.
The :codegen module clones two official Unicode repositories into
codegen/repos/ (gitignored, sparse, pinned to release tags).
unicode-org/cldr at release-48-2 is the
source of truth. The generator parses the LDML files, resolves each locale's
inheritance chain (parentLocales rules, root aliases), and emits the flattened
result as encoded string constants into the datetime module, plus the locale tag
list into the base module. Identical payloads are deduplicated: 1121 locales
plus root collapse to 429 unique constants, around 500 KB of Kotlin source. The
same pipeline emits the Country and Currency enums, the localized
country and currency names, and the per-locale number-formatting data. Names are
stored sparsely, holding only what each locale's own file declares with the
parent chain walked at runtime, because flattening them would multiply the data
many times over.
unicode-org/icu at release-78.3 is used
only for verification. The generator extracts golden fixtures for 30 major
locales from ICU's resource bundles (datetime patterns and names, country
display names, currency symbols and names, number separators), and generated
Icu*GoldenTests verify in each module's commonTest that the CLDR-derived
runtime data agrees with them on every platform. ICU encodes the same upstream
data through a completely different pipeline, so agreement is a strong check on
the parsers and the runtime resolution. ICU's full currency numeric-code table
is emitted as a fixture too, so the ISO 4217 cross-check also runs as a test
everywhere. On the JVM, additional parity tests compare the ISO country and
currency tables against the JDK's own data, a third independent source.
Currency identity (numeric codes and ISO minor units) is not in CLDR, so the
official ISO 4217 XML published by SIX is vendored as a snapshot and parsed
during generation: codegen/src/main/resources/iso4217/list-one.xml for the
active codes and list-three.xml for the withdrawn ones, both published
2026-01-01. List three omits the minor units field entirely, so a withdrawn
code takes CLDR's fraction data instead, and the JDK parity test is what
confirms the two agree for the codes the JDK knows.
Flag emoji are not CLDR either. The RGI flag sequences of UTS #51 are vendored
the same way at codegen/src/main/resources/emoji/emoji-sequences.txt from
Emoji 17.0, and used only to check at generation time that every country's
derived sequence is one Unicode recommends. Nothing from that file is compiled
into an artifact. The country set is CLDR's regular region validity
list restricted to codes with an ISO alpha-3 and numeric assignment, which
excludes macroregions, exceptionally reserved codes (AC, IC) and
user-assigned codes (XK).
To regenerate after bumping the pinned tags in
codegen/src/main/kotlin/.../Repos.kt:
./gradlew :codegen:generateLocaleDataThe task clones on first run and reuses the clones afterwards. Generated files
carry a // GENERATED header and are committed, so consumers of the library
never run the pipeline.
./gradlew buildThis compiles every target and runs the test suite on each platform the host can execute: JVM, Android host tests, Node.js for JS and both Wasm targets, macOS, and the iOS and watchOS simulators. Apple simulator tests skip themselves when the matching runtime is not installed in Xcode.
The library modules share their target list and publishing setup through the
convention plugins in build-logic/, an included build rather than buildSrc
so that a change to one plugin only invalidates its consumers.
Formatting is enforced with ktlint;
generated sources are excluded by their // GENERATED header. Run
./gradlew ktlintFormat to fix style before committing, or
./gradlew ktlintCheck to verify.
Every source file opens with the Apache notice, and a script keeps all of them identical to the copy in LICENSE:
python3 scripts/license_header.py check # name the files whose header is wrong
python3 scripts/license_header.py apply # rewrite themThe notice is read out of LICENSE rather than written down a second time, so
editing the copyright line there and running apply updates the whole tree.
Generated sources carry it as well: the emitters write the same notice above the
// GENERATED line, so regenerating does not strip it.
Each library module records its public ABI under <module>/api/: one
.klib.api file covering every Kotlin/Native, JS and Wasm target, plus
jvm/<module>.api for the JVM bytecode.
./gradlew checkKotlinAbi # compare the sources against the recorded ABI
./gradlew updateKotlinAbi # rewrite it after a deliberate API changeCommit the rewritten files together with the code that changed them, so the diff
shows what the change does to the published surface. The check is not part of
check, so ./gradlew build skips it: a complete comparison needs a klib for
every target and only a macOS host can build them all, so running it elsewhere
would compare a subset and still report success.
Other tasks worth knowing:
./gradlew sizeReport # every artifact against its gzipped budget
./gradlew updateSizeDoc # regenerate docs/size.md from that report
./gradlew checkLayeringRule # hand-written code names no specific enum entry
./gradlew -p samples/narrowed build # the plugin sample, against local artifactsCI runs on every push to main and on pull requests: the license header check,
ktlint, the layering check, plugin validation and a configuration-cache round
trip; the size budgets; the tests of the generator, the emitters and the Gradle
plugin; the narrowed sample built against locally published artifacts; an ABI
check on macOS; and one job per Kotlin target, running that target's tests where
the runner can execute them and compiling it where it cannot. Pull requests must
be green on all of it before merging.
What is intended and not yet built is in ROADMAP.md; where this library has decided to stop is in docs/boundaries.md.
LocalDate is not supported. Currency strings do parse back:
CurrencyAmount.parseFormatted reads CLDR-formatted values like
R$ 1.234,56 or 200 Ft into ISO minor units, expecting one number with one
locale's separators rather than free-form text.kotlinx-locale-datetime-cldr-relative,
but you choose the unit. Whether ninety minutes reads as "in 90 minutes" or
"in 2 hours" is not standardized by CLDR, ECMA-402 or ICU, all of which take
the unit from the caller.kotlinx-locale-datetime-cldr-durations and takes
the unit from you for the same reason. It agrees with ICU on all 10080 cells
of its conformance fixture, which is fourteen units at three widths and eight
values across thirty locales. CLDR has wording for 681 of the 1121 locales;
the rest fall back to English, as ICU does for them.w, W and F pattern fields and the numeric forms of e and c need
goldens of their own before they can be turned on.¤¤¤ with a count) are not implemented, though
the plural rules they need are.-platform modules do not read locale data on Linux, Windows, Android
Native or Wasm-WASI yet. The bundled -cldr-* modules answer on all of them,
so this only affects a build that chose the host's data; see
what each module answers.Apache License 2.0. See LICENSE.
CLDR and ICU data is used under the Unicode License.