
Strongly typed hierarchical translation system with explicit locale handling, lazy bundle registration, locale fallback and model mappers; optional UI integration and locale-aware caching for efficient resolution.
polyglot is a small Kotlin localization library focused on strongly typed translations, explicit locale handling and simple integration with or without UI frameworks.
The project is built as Kotlin Multiplatform for JVM, Android and iOS. Locale and translation APIs are available from commonMain. Java locale conversions are included in the JVM and Android variants of polyglot-locale.
polyglot-translation.The translation module works in command line applications and UI applications. Keep one localization model across both environments and add UI integration where it is needed.
Another important advantage is the ownership model. A feature can define and keep its own translation classes and bundles locally, and the application can later assemble them into one resolver through module trees. That keeps translations close to the code that actually uses them instead of forcing one global file or one central registry with unrelated strings mixed together.
| Gradle module and Maven artifact | Main package | Responsibility |
|---|---|---|
polyglot-locale |
net.igsoft.polyglot.locale |
Locale values, predefined tags, normalization and Java locale conversions. |
polyglot-translation |
net.igsoft.polyglot.translation |
Translation definitions, bundles, modules, mapper rules, registry and resolver. |
polyglot-translation-compose |
net.igsoft.polyglot.translation.compose |
Translation providers and lookups for Compose. |
Dependencies are polyglot-translation-compose → polyglot-translation → polyglot-locale.
Each dependency is exposed through api, so consumers add only the artifact they need.
Use polyglot-locale independently for locale handling without translation or Compose dependencies.
Add Maven Central to your repositories if it is not already configured:
repositories {
mavenCentral()
}Add the dependency you need:
For standalone locale handling:
dependencies {
implementation("net.igsoft.polyglot:polyglot-locale:<version>")
}For typed translations:
dependencies {
implementation("net.igsoft.polyglot:polyglot-translation:<version>")
}For Jetpack Compose or Compose Multiplatform integration:
dependencies {
implementation("net.igsoft.polyglot:polyglot-translation-compose:<version>")
}polyglot-locale provides Locale and Locales. The remaining types belong to polyglot-translation:
Locale: normalized locale value object based on language tags.Locales: predefined language, regional and script locales such as EN, EN_US, PT_BR and ZH_HANT_TW.Definition: common type for bundles and modules accepted by the registry.Bundle: translations for one locale.BundleScope: local collector of the models created by a bundle.ModuleScope: local collector of definitions and mapper rules declared by a module.Module: hierarchical grouping of bundles, nested modules and mapper rules.Registry: registration and resolution backend.Resolver: application-facing translation resolver with locale-aware caching.The library resolves translation objects by Kotlin type, not by string key.
Define your translation models as regular Kotlin classes:
data class GreetingStrings(
val hello: String,
val goodbye: String,
)Register locale bundles:
import net.igsoft.polyglot.locale.Locale
import net.igsoft.polyglot.locale.Locales
import net.igsoft.polyglot.translation.Bundle
import net.igsoft.polyglot.translation.BundleScope
class GreetingBundleEn : Bundle() {
override val locale: Locale = Locales.EN
override fun register(translations: BundleScope) {
translations.add(
GreetingStrings(
hello = "Hello",
goodbye = "Goodbye",
)
)
}
}Resolve translations through Resolver:
import net.igsoft.polyglot.translation.Registry
import net.igsoft.polyglot.translation.Resolver
val resolver = Resolver(
Registry(
listOf(GreetingBundleEn())
)
)
val strings = resolver.getTranslationClass<GreetingStrings>()
println(strings.hello)This works the same way in a command line app, desktop app, service or Android app.
The intended way to organize translations is hierarchical and local-first:
Module,Registry from top-level modules.This is especially useful in larger applications because it avoids one giant translation file or one giant registration object.
Example:
import net.igsoft.polyglot.locale.Locale
import net.igsoft.polyglot.locale.Locales
import net.igsoft.polyglot.translation.Bundle
import net.igsoft.polyglot.translation.BundleScope
import net.igsoft.polyglot.translation.Module
import net.igsoft.polyglot.translation.ModuleScope
import net.igsoft.polyglot.translation.Registry
import net.igsoft.polyglot.translation.Resolver
data class SearchStrings(
val placeholder: String,
val clear: String,
)
class SearchBundleEn : Bundle() {
override val locale: Locale = Locales.EN
override fun register(translations: BundleScope) {
translations.add(
SearchStrings(
placeholder = "Search",
clear = "Clear",
)
)
}
}
class SearchBundlePl : Bundle() {
override val locale: Locale = Locales.PL
override fun register(translations: BundleScope) {
translations.add(
SearchStrings(
placeholder = "Szukaj",
clear = "Wyczyść",
)
)
}
}
class SearchModule : Module() {
override fun register(registration: ModuleScope) {
registration.registerDefinition(SearchBundleEn())
registration.registerDefinition(SearchBundlePl())
}
}
class AppModule : Module() {
override fun register(registration: ModuleScope) {
registration.registerDefinition(SearchModule())
}
}
val resolver = Resolver(
Registry(
listOf(AppModule())
)
)That pattern scales from a tiny CLI tool to a large modular app.
Use Locale.of(...) to create normalized locales from a language tag string:
val localeA = Locale.of("pl-PL")Behavior:
languageTag.Java locale interop is included in polyglot-locale on JVM and Android. Import the conversion extensions and use an alias to distinguish Java's Locale:
import net.igsoft.polyglot.locale.Locale
import net.igsoft.polyglot.locale.of
import net.igsoft.polyglot.locale.toJavaLocale
import net.igsoft.polyglot.locale.toLocale
import java.util.Locale as JavaLocale
val localeB = Locale.of(JavaLocale.US)
val locale = JavaLocale.GERMANY.toLocale()
val javaLocale = locale.toJavaLocale()Use predefined values for common languages, regions and scripts:
val portuguese = Locales.PT
val brazilianPortuguese = Locales.PT_BR
val traditionalChineseTaiwan = Locales.ZH_HANT_TWLocales is first used; subsequent reads reuse the normalized tags,PT, PT_PT and PT_BR represent different locales,Locale.of(...) compare by normalized tag and work interchangeably as registry and cache keys,registeredLocales(),Locale.of(...) for tags outside the predefined catalog.Bundle defines translations for a single locale.
locale,register(translations: BundleScope) to collect the bundle's models,translations.add(instance) registers one translation object under its Kotlin type,translations.add<T>(instance) registers a model under an explicitly selected type,translations.add(instance, clazz) lets you register it explicitly under a selected type.Create fresh models inside register() and register the same translation types on each invocation.
The registry invokes this method lazily and publishes the collected models after it returns successfully.
It retains the bundle and calls the same method again when released models are requested.
Keep models and the collector local to the invocation so the bundle does not retain released translations.
This makes bundles a good fit for leaf-level module translations.
Module is the mechanism for hierarchical composition.
Implement register(registration: ModuleScope) and add definitions with registration.registerDefinition(...).
Bundles and modules implement Definition; use this type for mixed collections of definitions.
The registry applies the collected declarations after the entire module tree registers successfully and mapper duplicates are validated.
Bundle models remain lazy. Keep the collector local to register(); later additions do not update the registry.
Declare base conversions (S) -> T and application overrides (S, T) -> T through
ModuleScope.registerMapper(...) inside Module.register().
| Operation | Declaration | Use |
|---|---|---|
| Base conversion | registration.registerMapper<S, T> { source -> ... } |
Build a complete T from another model; a bundle for T is optional. |
| Regional inheritance | registration.registerMapper<T>(locale = ..., sourceLocale = ...) { base -> ... } |
Build a regional base by copying selected fields from another locale. |
| Application override | registration.registerMapper<S, T> { source, target -> ... } |
Copy selected fields into an existing T, preserving its other translations. |
Both ModuleScope and Registry provide these generic overloads and equivalent overloads accepting KClass values.
The locale parameters have separate roles:
locale selects the target locale where the mapper participates in resolution; omit it for a global mapper,sourceLocale selects the exact locale from which to resolve the source translation; set it when the source must come from a specific locale.Use a base conversion to build a complete translation object from another model:
data class CommonStrings(
val clear: String,
val back: String,
)
data class SearchFieldStrings(
val clear: String,
val back: String,
)
class SearchFieldMapper : Module() {
override fun register(registration: ModuleScope) {
registration.registerMapper<CommonStrings, SearchFieldStrings> { strings ->
SearchFieldStrings(
clear = strings.clear,
back = strings.back,
)
}
}
}This is useful when one feature-specific translation model can be built from a shared common translation model.
Base conversion behavior:
locale,locale to register a global mapper,sourceLocale selects the exact locale used to resolve the source; omit it to use the locale currently being resolved,Use a mapper with the same source and target type to override selected fields for a regional locale:
data class AccountStrings(
val phone: String,
val save: String,
val cancel: String,
)
class AccountBundlePt : Bundle() {
override val locale = Locales.PT
override fun register(translations: BundleScope) {
translations.add(
AccountStrings(
phone = "Telemóvel",
save = "Guardar",
cancel = "Cancelar",
)
)
}
}
class AccountMapper : Module() {
override fun register(registration: ModuleScope) {
registration.registerMapper<AccountStrings>(
locale = Locales.PT_BR,
sourceLocale = Locales.PT,
) {
it.copy(
phone = "Celular",
save = "Salvar",
)
}
}
}
val resolver = Resolver(
Registry(listOf(AccountBundlePt(), AccountMapper()))
)
val strings = resolver.getTranslationClass<AccountStrings>(Locales.PT_BR)
// AccountStrings(phone=Celular, save=Salvar, cancel=Cancelar)Here, locale = Locales.PT_BR selects the regional target and sourceLocale = Locales.PT selects its base.
Specify both for same-type regional inheritance so the mapper reads the base locale explicitly.
Use a two-argument mapper to override selected fields of an existing component translation.
For an application-wide override, omit both locale parameters: the mapper applies globally and its source follows
the original request's full fallback chain. Continuing the account example, register the shared save label for pt:
data class CommonActionStrings(
val save: String,
)
class CommonActionBundlePt : Bundle() {
override val locale = Locales.PT
override fun register(translations: BundleScope) {
translations.add(CommonActionStrings(save = "OK"))
}
}
class AccountApplicationMapper : Module() {
override fun register(registration: ModuleScope) {
registration.registerMapper<CommonActionStrings, AccountStrings> { common, account ->
account.copy(save = common.save)
}
}
}
val applicationResolver = Resolver(
Registry(
listOf(
AccountBundlePt(),
AccountMapper(),
CommonActionBundlePt(),
AccountApplicationMapper(),
)
)
)
val applicationStrings = applicationResolver.getTranslationClass<AccountStrings>(Locales.PT_BR)
// AccountStrings(phone=Celular, save=OK, cancel=Cancelar)The component's pt-BR base supplies Celular and Salvar. The application override resolves its source
independently through pt-BR -> pt -> en, finds OK in pt and changes only save.
The regional phone label and inherited cancel label retain their values.
To limit an application override to pt-BR, specify only locale inside Module.register():
registration.registerMapper<CommonActionStrings, AccountStrings>(
locale = Locales.PT_BR,
) { common, account ->
account.copy(save = common.save)
}The override participates at pt-BR in the target fallback chain. Its source still resolves independently through
the original request's full fallback chain. Leave sourceLocale omitted for this behavior; setting it to PT
would select exactly pt even when pt-BR common strings are available.
Application override behavior:
sourceLocale, resolve the source through the original request's full locale chain, including the default locale,sourceLocale, resolve the source for that exact locale,Duplicate mapper errors include both registration paths, for example:
Duplicate base mapper [translationClass='AccountStrings', locale='global'] [existing='Registry/SharedModule#1/base#1', incoming='Registry/AppModule#2/SharedModule#1/base#1']
Paths identify the complete module hierarchy. Numbers identify declaration attempts within the owning scope,
including failed attempts. Anonymous definitions use Module or Bundle as their name.
Direct registry mappers use Registry/mapper#N/base#1 or Registry/mapper#N/override#1.
Registration validates conflicts before publishing the collected declarations; a rejected module leaves existing
registrations, cached results and the registry revision unchanged.
Source bundles are registered lazily, including sources outside the requested locale's fallback chain.
Keep transformations immutable, for example with copy(), so the base translation remains unchanged.
Localized mapper target locales are included in registeredLocales().
Registry stores bundles and mappers and resolves translation classes internally.
Key behaviors:
Definition values in the constructor,registerDefinition(...),registerTranslationClass<T>(locale) { ... } or the overload accepting KClass<T>,pl-PL -> pl,registeredLocales().Resolver is the main entry point used by applications.
val resolver = Resolver(Registry(listOf(AppModule())))
resolver.setCurrentLocale(Locales.PL)
val stringsA = resolver.getTranslationClass<SearchStrings>()
val stringsB = resolver.getTranslationClass<SearchStrings>(Locales.EN_US)
val locales = resolver.registeredLocales()Behavior:
getTranslationClass<T>() resolves using the current locale,getTranslationClass<T>(locale) resolves using an explicit locale,KClass<T> instead of reified type parameters,setCurrentLocale(...) clears this resolver's cache and releases all bundle model instances held by the registry, including mapper sources,Releasing bundle models preserves translation definitions, mapper definitions and directly registered factories. Other resolvers sharing the registry retain their cached results until their own cache is invalidated. Models still referenced by application code or another resolver remain reachable; GC can reclaim them after those references are released.
Use DiagnosticResolver when translation provenance is needed. Pass its resolver to the application,
including AppLocalizationProvider, so ordinary and diagnostic lookups share the same cache:
import net.igsoft.polyglot.translation.DiagnosticResolver
val diagnostics = DiagnosticResolver(Registry(listOf(AppModule())))
val resolver = diagnostics.resolver
resolver.setCurrentLocale(Locales.PT_BR)
val strings = resolver.getTranslationClass<AccountStrings>()
val result = diagnostics.resolve<AccountStrings>()
check(result.value === strings)
check(result.diagnostics.cacheHit)
val explicit = diagnostics.resolve(AccountStrings::class, Locales.PT_BR)
println(explicit.diagnostics)resolve<T>() uses the resolver's current locale; resolve<T>(locale) uses an explicit locale.
Both have equivalents accepting KClass<T>. Each returns TranslationResolution<T> containing the actual
value and a TranslationDiagnostics snapshot:
requestedClass, requestedLocale and fallbackChain identify the request and ordered fallback candidates,cacheHit identifies whether this lookup reused a final model from this resolver's cache,usesEnglishFallback identifies selection of the default en candidate for the target or an implicit override source,origin describes the successful dependency tree through the sealed TranslationOrigin type.The origin variants are:
| Variant | Provenance |
|---|---|
BundleOrigin |
Model type, actual locale, bundle class and registration path. |
FactoryOrigin |
Model type, actual locale and direct factory registration path. |
BaseMapperOrigin |
Target model type, resolvedLocale, declared target locale (null for global), declared sourceLocale, registration path and resolved source. |
OverrideOrigin |
Target model type, declared target locale, declared sourceLocale, registration path, previous target and independently resolved source. |
For regional inheritance the tree preserves both locales:
requested: AccountStrings, pt-BR; fallbackChain: [pt-BR, pt, en]
BaseMapperOrigin(locale=pt-BR, resolvedLocale=pt-BR, sourceLocale=pt)
└─ BundleOrigin(locale=pt, bundleClass=AccountBundlePt)
An override adds separate target and source branches. Nested overrides remain in those branches.
Base conversions retain source bases without application overrides, following ordinary resolution semantics.
Explicit sourceLocale selects exactly that locale, including sources outside fallbackChain.
An explicit English source and language fallback en-US -> en do not set usesEnglishFallback.
Sources outside the request chain remain visible in the tree.
Diagnostics use the ordinary resolution algorithm and record provenance during the lookup that creates the value. Cache hits reuse its recorded origin without invoking bundles, factories or mappers again. Registry changes invalidate values and provenance together on the next lookup. Changing the current locale clears both caches and releases bundle models under the ordinary lifecycle rules. Other resolvers sharing the registry retain their own cached values and provenance. Previously returned reports remain historical snapshots.
Resolver(registry) allocates no diagnostic cache, traces or origin trees. The shared resolution path checks for
an optional trace. Registration paths are retained by every registry to explain duplicate mapper errors.
DiagnosticResolver adds trace work on cache misses and retains an origin tree per cached result, proportional
to its successful dependency occurrences. Diagnostic cache hits allocate only the result and report wrappers;
they reuse the origin tree. Reports retain class identifiers, locale values and paths, without retaining model
instances, definition instances or registration callbacks. TranslationResolution.value retains the returned model
like an ordinary lookup result.
Reports describe applied transformations, rather than all rejected candidates or individual field values.
Missing translations, cycles and callback failures retain ordinary exception behavior; failed lookups are not cached.
Create a DiagnosticResolver before reproducing an issue: provenance cannot be reconstructed from a cache
created by an independent ordinary resolver.
Replace Registry.registerBundle(definition) and ModuleScope.registerBundle(definition) with
registerDefinition(definition), including callable references such as registration::registerDefinition.
The parameter remains Definition and accepts both bundles and modules. BundleScope.add(...), constructor
registration, mapper precedence, fallback rules and Compose APIs keep their existing contracts.
The old method is removed without a compatibility wrapper; consumers must recompile against the updated API.
The working version remains 0.4.0 for local development; publish these incompatible changes under a new version.
Because polyglot-translation has no UI dependency, it can be used directly in small console tools:
fun main() {
val resolver = Resolver(
Registry(
listOf(AppModule())
)
)
resolver.setCurrentLocale(Locale.of("pl-PL"))
val strings = resolver.getTranslationClass<SearchStrings>()
println(strings.placeholder)
}This is one of the main strengths of the library: the same translation model can be reused in CLI tools, background jobs, backend services and UI applications.
Wrap your UI with AppLocalizationProvider and resolve typed translations with rememberTranslations():
import androidx.compose.runtime.Composable
import net.igsoft.polyglot.translation.Registry
import net.igsoft.polyglot.translation.Resolver
import net.igsoft.polyglot.translation.compose.AppLocalizationProvider
import net.igsoft.polyglot.translation.compose.rememberTranslations
@Composable
fun App() {
val resolver = Resolver(
Registry(
listOf(GreetingBundleEn())
)
)
AppLocalizationProvider(polyglotResolver = resolver) {
val strings = rememberTranslations<GreetingStrings>()
GreetingScreen(strings)
}
}Compose API:
AppLocalizationProvider(polyglotResolver, initialLocale, content) uses an explicit resolver,rememberTranslations<MyStrings>() resolves typed translations from the current composition,rememberTranslations<MyStrings>(polyglotResolver) uses an explicit resolver and the composition locale; both forms also accept KClass<MyStrings>,LocalLocale explicitly and reuse the resolver's revision-aware cache,LocalLocale exposes the active Locale,LocalSetLocale lets the UI switch locale with Locale,LocalPolyglotResolver exposes the resolver to nested composables.This keeps the UI integration thin. Locale state lives in Compose, but translation ownership and registration still stay in polyglot-translation.
After incremental registration, recompose the consuming content to display the updated translations.
./gradlew buildOn Windows:
.\gradlew.bat build --console=plain --no-daemonThe build uses com.vanniktech.maven.publish and is configured for Maven Central publication.
Required Gradle properties can be passed through environment variables:
ORG_GRADLE_PROJECT_mavenCentralUsernameORG_GRADLE_PROJECT_mavenCentralPasswordORG_GRADLE_PROJECT_signingInMemoryKeyIdORG_GRADLE_PROJECT_signingInMemoryKeyORG_GRADLE_PROJECT_signingInMemoryKeyPasswordPublish and release with:
./gradlew publishAndReleaseToMavenCentralVersions are derived from the current Git branch:
main and master publish the base version,-SNAPSHOT.polyglot is a small Kotlin localization library focused on strongly typed translations, explicit locale handling and simple integration with or without UI frameworks.
The project is built as Kotlin Multiplatform for JVM, Android and iOS. Locale and translation APIs are available from commonMain. Java locale conversions are included in the JVM and Android variants of polyglot-locale.
polyglot-translation.The translation module works in command line applications and UI applications. Keep one localization model across both environments and add UI integration where it is needed.
Another important advantage is the ownership model. A feature can define and keep its own translation classes and bundles locally, and the application can later assemble them into one resolver through module trees. That keeps translations close to the code that actually uses them instead of forcing one global file or one central registry with unrelated strings mixed together.
| Gradle module and Maven artifact | Main package | Responsibility |
|---|---|---|
polyglot-locale |
net.igsoft.polyglot.locale |
Locale values, predefined tags, normalization and Java locale conversions. |
polyglot-translation |
net.igsoft.polyglot.translation |
Translation definitions, bundles, modules, mapper rules, registry and resolver. |
polyglot-translation-compose |
net.igsoft.polyglot.translation.compose |
Translation providers and lookups for Compose. |
Dependencies are polyglot-translation-compose → polyglot-translation → polyglot-locale.
Each dependency is exposed through api, so consumers add only the artifact they need.
Use polyglot-locale independently for locale handling without translation or Compose dependencies.
Add Maven Central to your repositories if it is not already configured:
repositories {
mavenCentral()
}Add the dependency you need:
For standalone locale handling:
dependencies {
implementation("net.igsoft.polyglot:polyglot-locale:<version>")
}For typed translations:
dependencies {
implementation("net.igsoft.polyglot:polyglot-translation:<version>")
}For Jetpack Compose or Compose Multiplatform integration:
dependencies {
implementation("net.igsoft.polyglot:polyglot-translation-compose:<version>")
}polyglot-locale provides Locale and Locales. The remaining types belong to polyglot-translation:
Locale: normalized locale value object based on language tags.Locales: predefined language, regional and script locales such as EN, EN_US, PT_BR and ZH_HANT_TW.Definition: common type for bundles and modules accepted by the registry.Bundle: translations for one locale.BundleScope: local collector of the models created by a bundle.ModuleScope: local collector of definitions and mapper rules declared by a module.Module: hierarchical grouping of bundles, nested modules and mapper rules.Registry: registration and resolution backend.Resolver: application-facing translation resolver with locale-aware caching.The library resolves translation objects by Kotlin type, not by string key.
Define your translation models as regular Kotlin classes:
data class GreetingStrings(
val hello: String,
val goodbye: String,
)Register locale bundles:
import net.igsoft.polyglot.locale.Locale
import net.igsoft.polyglot.locale.Locales
import net.igsoft.polyglot.translation.Bundle
import net.igsoft.polyglot.translation.BundleScope
class GreetingBundleEn : Bundle() {
override val locale: Locale = Locales.EN
override fun register(translations: BundleScope) {
translations.add(
GreetingStrings(
hello = "Hello",
goodbye = "Goodbye",
)
)
}
}Resolve translations through Resolver:
import net.igsoft.polyglot.translation.Registry
import net.igsoft.polyglot.translation.Resolver
val resolver = Resolver(
Registry(
listOf(GreetingBundleEn())
)
)
val strings = resolver.getTranslationClass<GreetingStrings>()
println(strings.hello)This works the same way in a command line app, desktop app, service or Android app.
The intended way to organize translations is hierarchical and local-first:
Module,Registry from top-level modules.This is especially useful in larger applications because it avoids one giant translation file or one giant registration object.
Example:
import net.igsoft.polyglot.locale.Locale
import net.igsoft.polyglot.locale.Locales
import net.igsoft.polyglot.translation.Bundle
import net.igsoft.polyglot.translation.BundleScope
import net.igsoft.polyglot.translation.Module
import net.igsoft.polyglot.translation.ModuleScope
import net.igsoft.polyglot.translation.Registry
import net.igsoft.polyglot.translation.Resolver
data class SearchStrings(
val placeholder: String,
val clear: String,
)
class SearchBundleEn : Bundle() {
override val locale: Locale = Locales.EN
override fun register(translations: BundleScope) {
translations.add(
SearchStrings(
placeholder = "Search",
clear = "Clear",
)
)
}
}
class SearchBundlePl : Bundle() {
override val locale: Locale = Locales.PL
override fun register(translations: BundleScope) {
translations.add(
SearchStrings(
placeholder = "Szukaj",
clear = "Wyczyść",
)
)
}
}
class SearchModule : Module() {
override fun register(registration: ModuleScope) {
registration.registerDefinition(SearchBundleEn())
registration.registerDefinition(SearchBundlePl())
}
}
class AppModule : Module() {
override fun register(registration: ModuleScope) {
registration.registerDefinition(SearchModule())
}
}
val resolver = Resolver(
Registry(
listOf(AppModule())
)
)That pattern scales from a tiny CLI tool to a large modular app.
Use Locale.of(...) to create normalized locales from a language tag string:
val localeA = Locale.of("pl-PL")Behavior:
languageTag.Java locale interop is included in polyglot-locale on JVM and Android. Import the conversion extensions and use an alias to distinguish Java's Locale:
import net.igsoft.polyglot.locale.Locale
import net.igsoft.polyglot.locale.of
import net.igsoft.polyglot.locale.toJavaLocale
import net.igsoft.polyglot.locale.toLocale
import java.util.Locale as JavaLocale
val localeB = Locale.of(JavaLocale.US)
val locale = JavaLocale.GERMANY.toLocale()
val javaLocale = locale.toJavaLocale()Use predefined values for common languages, regions and scripts:
val portuguese = Locales.PT
val brazilianPortuguese = Locales.PT_BR
val traditionalChineseTaiwan = Locales.ZH_HANT_TWLocales is first used; subsequent reads reuse the normalized tags,PT, PT_PT and PT_BR represent different locales,Locale.of(...) compare by normalized tag and work interchangeably as registry and cache keys,registeredLocales(),Locale.of(...) for tags outside the predefined catalog.Bundle defines translations for a single locale.
locale,register(translations: BundleScope) to collect the bundle's models,translations.add(instance) registers one translation object under its Kotlin type,translations.add<T>(instance) registers a model under an explicitly selected type,translations.add(instance, clazz) lets you register it explicitly under a selected type.Create fresh models inside register() and register the same translation types on each invocation.
The registry invokes this method lazily and publishes the collected models after it returns successfully.
It retains the bundle and calls the same method again when released models are requested.
Keep models and the collector local to the invocation so the bundle does not retain released translations.
This makes bundles a good fit for leaf-level module translations.
Module is the mechanism for hierarchical composition.
Implement register(registration: ModuleScope) and add definitions with registration.registerDefinition(...).
Bundles and modules implement Definition; use this type for mixed collections of definitions.
The registry applies the collected declarations after the entire module tree registers successfully and mapper duplicates are validated.
Bundle models remain lazy. Keep the collector local to register(); later additions do not update the registry.
Declare base conversions (S) -> T and application overrides (S, T) -> T through
ModuleScope.registerMapper(...) inside Module.register().
| Operation | Declaration | Use |
|---|---|---|
| Base conversion | registration.registerMapper<S, T> { source -> ... } |
Build a complete T from another model; a bundle for T is optional. |
| Regional inheritance | registration.registerMapper<T>(locale = ..., sourceLocale = ...) { base -> ... } |
Build a regional base by copying selected fields from another locale. |
| Application override | registration.registerMapper<S, T> { source, target -> ... } |
Copy selected fields into an existing T, preserving its other translations. |
Both ModuleScope and Registry provide these generic overloads and equivalent overloads accepting KClass values.
The locale parameters have separate roles:
locale selects the target locale where the mapper participates in resolution; omit it for a global mapper,sourceLocale selects the exact locale from which to resolve the source translation; set it when the source must come from a specific locale.Use a base conversion to build a complete translation object from another model:
data class CommonStrings(
val clear: String,
val back: String,
)
data class SearchFieldStrings(
val clear: String,
val back: String,
)
class SearchFieldMapper : Module() {
override fun register(registration: ModuleScope) {
registration.registerMapper<CommonStrings, SearchFieldStrings> { strings ->
SearchFieldStrings(
clear = strings.clear,
back = strings.back,
)
}
}
}This is useful when one feature-specific translation model can be built from a shared common translation model.
Base conversion behavior:
locale,locale to register a global mapper,sourceLocale selects the exact locale used to resolve the source; omit it to use the locale currently being resolved,Use a mapper with the same source and target type to override selected fields for a regional locale:
data class AccountStrings(
val phone: String,
val save: String,
val cancel: String,
)
class AccountBundlePt : Bundle() {
override val locale = Locales.PT
override fun register(translations: BundleScope) {
translations.add(
AccountStrings(
phone = "Telemóvel",
save = "Guardar",
cancel = "Cancelar",
)
)
}
}
class AccountMapper : Module() {
override fun register(registration: ModuleScope) {
registration.registerMapper<AccountStrings>(
locale = Locales.PT_BR,
sourceLocale = Locales.PT,
) {
it.copy(
phone = "Celular",
save = "Salvar",
)
}
}
}
val resolver = Resolver(
Registry(listOf(AccountBundlePt(), AccountMapper()))
)
val strings = resolver.getTranslationClass<AccountStrings>(Locales.PT_BR)
// AccountStrings(phone=Celular, save=Salvar, cancel=Cancelar)Here, locale = Locales.PT_BR selects the regional target and sourceLocale = Locales.PT selects its base.
Specify both for same-type regional inheritance so the mapper reads the base locale explicitly.
Use a two-argument mapper to override selected fields of an existing component translation.
For an application-wide override, omit both locale parameters: the mapper applies globally and its source follows
the original request's full fallback chain. Continuing the account example, register the shared save label for pt:
data class CommonActionStrings(
val save: String,
)
class CommonActionBundlePt : Bundle() {
override val locale = Locales.PT
override fun register(translations: BundleScope) {
translations.add(CommonActionStrings(save = "OK"))
}
}
class AccountApplicationMapper : Module() {
override fun register(registration: ModuleScope) {
registration.registerMapper<CommonActionStrings, AccountStrings> { common, account ->
account.copy(save = common.save)
}
}
}
val applicationResolver = Resolver(
Registry(
listOf(
AccountBundlePt(),
AccountMapper(),
CommonActionBundlePt(),
AccountApplicationMapper(),
)
)
)
val applicationStrings = applicationResolver.getTranslationClass<AccountStrings>(Locales.PT_BR)
// AccountStrings(phone=Celular, save=OK, cancel=Cancelar)The component's pt-BR base supplies Celular and Salvar. The application override resolves its source
independently through pt-BR -> pt -> en, finds OK in pt and changes only save.
The regional phone label and inherited cancel label retain their values.
To limit an application override to pt-BR, specify only locale inside Module.register():
registration.registerMapper<CommonActionStrings, AccountStrings>(
locale = Locales.PT_BR,
) { common, account ->
account.copy(save = common.save)
}The override participates at pt-BR in the target fallback chain. Its source still resolves independently through
the original request's full fallback chain. Leave sourceLocale omitted for this behavior; setting it to PT
would select exactly pt even when pt-BR common strings are available.
Application override behavior:
sourceLocale, resolve the source through the original request's full locale chain, including the default locale,sourceLocale, resolve the source for that exact locale,Duplicate mapper errors include both registration paths, for example:
Duplicate base mapper [translationClass='AccountStrings', locale='global'] [existing='Registry/SharedModule#1/base#1', incoming='Registry/AppModule#2/SharedModule#1/base#1']
Paths identify the complete module hierarchy. Numbers identify declaration attempts within the owning scope,
including failed attempts. Anonymous definitions use Module or Bundle as their name.
Direct registry mappers use Registry/mapper#N/base#1 or Registry/mapper#N/override#1.
Registration validates conflicts before publishing the collected declarations; a rejected module leaves existing
registrations, cached results and the registry revision unchanged.
Source bundles are registered lazily, including sources outside the requested locale's fallback chain.
Keep transformations immutable, for example with copy(), so the base translation remains unchanged.
Localized mapper target locales are included in registeredLocales().
Registry stores bundles and mappers and resolves translation classes internally.
Key behaviors:
Definition values in the constructor,registerDefinition(...),registerTranslationClass<T>(locale) { ... } or the overload accepting KClass<T>,pl-PL -> pl,registeredLocales().Resolver is the main entry point used by applications.
val resolver = Resolver(Registry(listOf(AppModule())))
resolver.setCurrentLocale(Locales.PL)
val stringsA = resolver.getTranslationClass<SearchStrings>()
val stringsB = resolver.getTranslationClass<SearchStrings>(Locales.EN_US)
val locales = resolver.registeredLocales()Behavior:
getTranslationClass<T>() resolves using the current locale,getTranslationClass<T>(locale) resolves using an explicit locale,KClass<T> instead of reified type parameters,setCurrentLocale(...) clears this resolver's cache and releases all bundle model instances held by the registry, including mapper sources,Releasing bundle models preserves translation definitions, mapper definitions and directly registered factories. Other resolvers sharing the registry retain their cached results until their own cache is invalidated. Models still referenced by application code or another resolver remain reachable; GC can reclaim them after those references are released.
Use DiagnosticResolver when translation provenance is needed. Pass its resolver to the application,
including AppLocalizationProvider, so ordinary and diagnostic lookups share the same cache:
import net.igsoft.polyglot.translation.DiagnosticResolver
val diagnostics = DiagnosticResolver(Registry(listOf(AppModule())))
val resolver = diagnostics.resolver
resolver.setCurrentLocale(Locales.PT_BR)
val strings = resolver.getTranslationClass<AccountStrings>()
val result = diagnostics.resolve<AccountStrings>()
check(result.value === strings)
check(result.diagnostics.cacheHit)
val explicit = diagnostics.resolve(AccountStrings::class, Locales.PT_BR)
println(explicit.diagnostics)resolve<T>() uses the resolver's current locale; resolve<T>(locale) uses an explicit locale.
Both have equivalents accepting KClass<T>. Each returns TranslationResolution<T> containing the actual
value and a TranslationDiagnostics snapshot:
requestedClass, requestedLocale and fallbackChain identify the request and ordered fallback candidates,cacheHit identifies whether this lookup reused a final model from this resolver's cache,usesEnglishFallback identifies selection of the default en candidate for the target or an implicit override source,origin describes the successful dependency tree through the sealed TranslationOrigin type.The origin variants are:
| Variant | Provenance |
|---|---|
BundleOrigin |
Model type, actual locale, bundle class and registration path. |
FactoryOrigin |
Model type, actual locale and direct factory registration path. |
BaseMapperOrigin |
Target model type, resolvedLocale, declared target locale (null for global), declared sourceLocale, registration path and resolved source. |
OverrideOrigin |
Target model type, declared target locale, declared sourceLocale, registration path, previous target and independently resolved source. |
For regional inheritance the tree preserves both locales:
requested: AccountStrings, pt-BR; fallbackChain: [pt-BR, pt, en]
BaseMapperOrigin(locale=pt-BR, resolvedLocale=pt-BR, sourceLocale=pt)
└─ BundleOrigin(locale=pt, bundleClass=AccountBundlePt)
An override adds separate target and source branches. Nested overrides remain in those branches.
Base conversions retain source bases without application overrides, following ordinary resolution semantics.
Explicit sourceLocale selects exactly that locale, including sources outside fallbackChain.
An explicit English source and language fallback en-US -> en do not set usesEnglishFallback.
Sources outside the request chain remain visible in the tree.
Diagnostics use the ordinary resolution algorithm and record provenance during the lookup that creates the value. Cache hits reuse its recorded origin without invoking bundles, factories or mappers again. Registry changes invalidate values and provenance together on the next lookup. Changing the current locale clears both caches and releases bundle models under the ordinary lifecycle rules. Other resolvers sharing the registry retain their own cached values and provenance. Previously returned reports remain historical snapshots.
Resolver(registry) allocates no diagnostic cache, traces or origin trees. The shared resolution path checks for
an optional trace. Registration paths are retained by every registry to explain duplicate mapper errors.
DiagnosticResolver adds trace work on cache misses and retains an origin tree per cached result, proportional
to its successful dependency occurrences. Diagnostic cache hits allocate only the result and report wrappers;
they reuse the origin tree. Reports retain class identifiers, locale values and paths, without retaining model
instances, definition instances or registration callbacks. TranslationResolution.value retains the returned model
like an ordinary lookup result.
Reports describe applied transformations, rather than all rejected candidates or individual field values.
Missing translations, cycles and callback failures retain ordinary exception behavior; failed lookups are not cached.
Create a DiagnosticResolver before reproducing an issue: provenance cannot be reconstructed from a cache
created by an independent ordinary resolver.
Replace Registry.registerBundle(definition) and ModuleScope.registerBundle(definition) with
registerDefinition(definition), including callable references such as registration::registerDefinition.
The parameter remains Definition and accepts both bundles and modules. BundleScope.add(...), constructor
registration, mapper precedence, fallback rules and Compose APIs keep their existing contracts.
The old method is removed without a compatibility wrapper; consumers must recompile against the updated API.
The working version remains 0.4.0 for local development; publish these incompatible changes under a new version.
Because polyglot-translation has no UI dependency, it can be used directly in small console tools:
fun main() {
val resolver = Resolver(
Registry(
listOf(AppModule())
)
)
resolver.setCurrentLocale(Locale.of("pl-PL"))
val strings = resolver.getTranslationClass<SearchStrings>()
println(strings.placeholder)
}This is one of the main strengths of the library: the same translation model can be reused in CLI tools, background jobs, backend services and UI applications.
Wrap your UI with AppLocalizationProvider and resolve typed translations with rememberTranslations():
import androidx.compose.runtime.Composable
import net.igsoft.polyglot.translation.Registry
import net.igsoft.polyglot.translation.Resolver
import net.igsoft.polyglot.translation.compose.AppLocalizationProvider
import net.igsoft.polyglot.translation.compose.rememberTranslations
@Composable
fun App() {
val resolver = Resolver(
Registry(
listOf(GreetingBundleEn())
)
)
AppLocalizationProvider(polyglotResolver = resolver) {
val strings = rememberTranslations<GreetingStrings>()
GreetingScreen(strings)
}
}Compose API:
AppLocalizationProvider(polyglotResolver, initialLocale, content) uses an explicit resolver,rememberTranslations<MyStrings>() resolves typed translations from the current composition,rememberTranslations<MyStrings>(polyglotResolver) uses an explicit resolver and the composition locale; both forms also accept KClass<MyStrings>,LocalLocale explicitly and reuse the resolver's revision-aware cache,LocalLocale exposes the active Locale,LocalSetLocale lets the UI switch locale with Locale,LocalPolyglotResolver exposes the resolver to nested composables.This keeps the UI integration thin. Locale state lives in Compose, but translation ownership and registration still stay in polyglot-translation.
After incremental registration, recompose the consuming content to display the updated translations.
./gradlew buildOn Windows:
.\gradlew.bat build --console=plain --no-daemonThe build uses com.vanniktech.maven.publish and is configured for Maven Central publication.
Required Gradle properties can be passed through environment variables:
ORG_GRADLE_PROJECT_mavenCentralUsernameORG_GRADLE_PROJECT_mavenCentralPasswordORG_GRADLE_PROJECT_signingInMemoryKeyIdORG_GRADLE_PROJECT_signingInMemoryKeyORG_GRADLE_PROJECT_signingInMemoryKeyPasswordPublish and release with:
./gradlew publishAndReleaseToMavenCentralVersions are derived from the current Git branch:
main and master publish the base version,-SNAPSHOT.