i18n

Translations for Kotlin Multiplatform. YAML in, typed Kotlin out, mistakes caught at build time.

Why translations are a build problem

Most translation setups look up a string by key at runtime. That means a renamed key, a missing language, or a placeholder the translator dropped are all discovered the same way: a user sees something wrong. The failure is silent, it is in production, and it is usually in the language nobody on the team reads.

The interesting part of the problem is therefore not the lookup — it is everything around it. Which keys exist? Which languages must be complete? Does every language agree on the placeholders? Those are questions a build can answer.

The runtime here is genuinely small — a map, a fallback chain and a single-pass substitution. The part worth the effort was the build step, because that is where you find out that the Swiss catalog has been missing a key since March.

What it looks like

Write a catalog per language. The fallback language defines the API surface.

# src/commonMain/i18n/messages.en.yaml
checkout:
  greeting: "Hello {{name}}!"
  items_one: "{{count}} item in your basket"
  items_other: "{{count}} items in your basket"

The Gradle plugin turns that into typed accessors and a baked catalog. Call them like functions:

val i18n = I18n(locale = Locale("de"), fallback = Locale("en")) {
    install(AppI18nCatalog)
}

i18n.translate.checkout.greeting(name = "Alice")   // named params, no string keys
i18n.translate.checkout.items(count = 3)           // plural form picked for you

A key that no longer exists will not compile, and a translation that invents a placeholder nobody supplies fails the build rather than the checkout page.

What you get

Typed accessors

Generated from the fallback catalog. Placeholders become named parameters — a forgotten one is a compile error.

Build-time checks

A missing required language, or a translation inventing an unknown placeholder, fails the build. Softer findings warn, and become errors under -Pi18n.strict.

Locale fallback chain

de-CH falls back to de, then to the configured fallback. A regional catalog only carries what differs.

Catalog precedence

Each module ships its own catalog. An app installs later and wins on shared keys, so framework strings can be overridden without forking.

Plurals

_one / _other suffixes, selected by count, with {{count}} supplied automatically.

Reactive in Kraft

Switching language re-renders subscribed components. The choice persists in localStorage.

Scope, honestly

The locale model covers language and region — de, de-CH. Script and variant subtags such as zh-Hant are not modelled: the second subtag is always read as a region, so those tags are mis-parsed. Plural selection is currently the simple count == 1 rule rather than full CLDR, and i18n.format is a seam with a default implementation rather than locale-aware date and number formatting.

Those three gaps are known and deliberate — shipping the build step mattered more than shipping CLDR. The seams are in place so filling them in later does not move any call sites.