Catalogs & Codegen
YAML in, typed Kotlin out — and a checker that fails the build before your users find the gap.
Where catalogs live
One YAML file per locale, under the source set that owns them:
src/commonMain/i18n/messages.en.yaml
src/commonMain/i18n/messages.de.yaml
src/commonMain/i18n/messages.de-CH.yaml Keys nest freely; nesting becomes a dotted key. Reserved Kotlin words and hyphenated keys are handled by the emitter, so you can name things naturally.
checkout:
greeting: "Hello {{name}}!"
is: "reserved words are fine"
greet-user: "Hi {{first-name}}"
items_one: "{{count}} item"
items_other: "{{count}} items"
nested:
deep: "Deep {{value}}" Wiring the plugin
apply<I18nPlugin>()
configure<I18nExtension> {
packageName.set("com.example.app.i18n")
moduleName.set("App") // prefix for generated names; defaults to the project name
fallbackLang.set("en") // defines the generated API surface
requiredLangs("de") // must be at full key parity with the fallback
// sourceSet.set("main") // for a plain kotlin("jvm") module; default is commonMain
} Generated code lands in build/generated/i18n/<sourceSet>/kotlin and is registered
on the source set through the task provider, so every compilation depends on generation
automatically. There is no manual wiring and no committed generated code.
What gets generated
Two things: a baked catalog object holding every locale's strings, and typed accessors hanging off the open translation surface.
// Generated — accessors named after your namespaces
val I18nTranslate.checkout: CheckoutI18n get() = CheckoutI18n(i18n)
// Usage — placeholders are named parameters
i18n.translate.checkout.greeting(name = "Alice")
i18n.translate.checkout.items(count = 3)
i18n.translate.checkout.nested.deep(value = "x") The fallback catalog alone defines the API. Other languages may omit keys — they fall through the chain — but they can never introduce one the fallback does not have. That is what keeps the generated surface stable and makes a removed key a compile error at every call site.
The checker
checkI18n is wired into check, so it runs with your tests. It compares every
non-fallback catalog against the fallback:
| Finding | Severity |
|---|---|
A requiredLangs language is missing a key, or absent entirely | Error — fails the build |
| A locale introduces a placeholder the fallback lacks | Error — fails the build |
| A locale defines a key the fallback does not have | Warning — error under -Pi18n.strict |
| An optional language is incomplete | Warning — error under -Pi18n.strict |
| A locale omits a placeholder the fallback has | Info — logged only, never fails |
A report is written to build/reports/i18n/check.txt. Note that
requiredLangs takes base languages only — a region inherits from its base, so
requiring de-CH is rejected outright rather than silently doing nothing.
{{count}} still produces a grammatical sentence, just one missing a number,
and there are languages where leaving it out is the correct translation.
One catalog per module
Each module generates and ships its own catalog. An application installs them in order, and later installs win on shared keys:
val i18n = I18n(locale, fallback) {
install(KraftFormsI18nCatalog) // framework first
install(AppI18nCatalog) // app last — overrides on conflict
} So an app can restate a framework string without forking the framework, and a library can ship sensible defaults without dictating them.