Library / SDK
adrielcafe/lyricist avatar
adrielcafe/lyricist

Lyricist: type safe strings for Compose Multiplatform

🌎 The missing I18N/L10N (internationalization/localization) multiplatform library for Compose Multiplatform!

811 stars32 forksKotlinMIT

At a glance

What is it?
Lyricist is an MIT licensed Kotlin library that replaces Android string resources with generated Kotlin values for Compose Multiplatform, covering Android, desktop, iOS and web, with locale switching at runtime and code generation through KSP. It suits a Compose team that wants its translations to be checked by the compiler, and it is a poor fit if you want to keep editing strings.xml.
Who is it for?
Lyricist suits a Compose Multiplatform team whose translations should be checked by the compiler rather than looked up by string key at runtime, since the strings become ordinary Kotlin values with a generated CompositionLocal, and the plural handling that Android forces into XML resources becomes a when expression in code.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 94 days ago.
What is it written in?
Mainly Kotlin, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Strings become Kotlin values instead of resource lookups

Lyricist is described as a missing internationalization and localization multiplatform library for Jetpack Compose, and the argument in the README is short and pointed.

Jetpack Compose greatly improved the way we build UIs on Android, but not the way we interact with strings. The built in stringResource() works well, but it does not benefit from idiomatic Kotlin in the way Compose does.

The pitch is to make working with strings as powerful as building user interfaces with Compose. Parameterized strings become type safe, plurals are handled with a when expression with more flexibility, and the strings can even be loaded and updated dynamically through an API.

The mechanism is that your translations stop being key value lookups and become Kotlin declarations. You declare the strings on a data class, a class or an interface, and the values can be almost anything.

kotlin
data class Strings(
    val simple: String,
    val annotated: AnnotatedString,
    val parameter: (locale: String) -> String,
    val plural: (count: Int) -> String,
    val list: List<String>,
    val nestedStrings: NestedStrings(),
)

So a translation entry can be a plain string, an annotated string with styling, a function that takes the current locale, a function that takes a count, a list of strings, or a nested structure of other string classes. The library generates the accessors, which means a missing translation is a compile error rather than a blank label at runtime.

The name comes from the accompanist library, and the reasoning is a small joke: music composing is done by a composer, and since this library is about writing strings rather than lyrics, the role of a lyricist felt like a good name.

An annotation marks each language, and a CompositionLocal carries the result

The next step is to create an instance of your strings class for each supported language and annotate it with @LyricistStrings, where the languageTag must be a compliant IETF BCP47 language tag, and one of them must be flagged as the default.

kotlin
@LyricistStrings(languageTag = Locales.EN, default = true)
val EnStrings = Strings(
    simple = "Hello Compose!",
    parameter = { locale ->
        "Current locale: $locale"
    },
)

@LyricistStrings(languageTag = Locales.PT)
val PtStrings = Strings(/* pt strings */)

Note the requirement that the tag be BCP47 compliant, which is a stricter contract than a free form locale name, and the requirement to mark a default, which is what makes a fallback possible when a translation is missing at runtime for a locale you did not ship.

From that declaration, Lyricist generates the LocalStrings property, a CompositionLocal that provides the strings of the current locale, and it also generates rememberStrings() and ProvideStrings(), which you call to make LocalStrings accessible down the tree.

kotlin
val lyricist = rememberStrings()

ProvideStrings(lyricist) {
    // Content
}

Reading the strings then looks like ordinary Compose code.

kotlin
val strings = LocalStrings.current

Text(text = strings.simple)

The advantage over stringResource is type safety on the key as well as the value. A renamed field stops compiling at every call site, and a translation that omits a field does too, which is the failure mode that resource based localisation is notoriously bad at catching before release.

Plurals become a when expression

The plural handling is where the library departs most visibly from the Android resource model, and it is the clearest example of the Compose style argument.

In a strings.xml file, plurals are expressed with a quantity resource, one item for one, another for other, and the platform picks between them, with the Android 7.0 format adding a few more cases such as zero, two, few, many and many more for languages with complex plural rules. Lyricist expresses the same thing as a Kotlin function.

kotlin
    plural = { count ->
        val value = when (count) {
            0 -> "no"
            1, 2 -> "a few"
            in 3..10 -> "a bunch of"
            else -> "a lot of"
        }
        "I have $value apples"
    },

The consequence is that the plural rules are code, so they can be anything, and they are written by the same people who write the rest of the Kotlin. The trade is that the rules for each language live in the translation file rather than being derived from the platform, so a translator now has to supply the logic rather than just the words.

That is a real shift in the translator's job, and it is worth being clear about it before adopting the library. The README's own limitation note says the XML processor does not handle the few and many plural values, which is a reminder that the two worlds do not map one to one, and that pull requests are welcome.

For the caller, the usage is unremarkable.

kotlin
Text(text = strings.plural(1))
Text(text = strings.plural(5))
Text(text = strings.plural(20))

which the README shows producing a few, a bunch of and a lot of apples.

KSP does the code generation, and there are two processors

The code generation runs through KSP, the Kotlin Symbol Processing plugin, and the repository layout shows the structure of it.

There is a lyricist-core module, a lyricist-compose module, and two processors, lyricist-processor-compose and lyricist-processor-xml. That split is the design in one line. The processor that reads Kotlin declarations produces Compose code, and the processor that reads XML resources produces the same Kotlin shape, which is how the easy migration from strings.xml path works.

The README also documents a knob for the generated code itself, which is the kind of detail that usually only appears once someone has needed it.

gradle
ksp {
    arg("lyricist.internalVisibility", "true")
}

Providing this optional argument to KSP in the module build file controls whether the generated code is public or internal. That matters in a multi-module project, where generated code that is public by default leaks the string API into every module's public surface.

The other build integration point is the module inclusion, and the repository has shared Gradle files for the two cases, android-module.gradle and kotlin-module.gradle, plus a kotlin-js-store directory for the web target. The samples directory shows the intended usage patterns, with a sample app, a sample-multi-module app, a sample-multiplatform app, a sample-multiplatform-ios app and a sample-xml project.

The repository is MIT licensed with a LICENSE.md file, it publishes to Maven Central under the cafe.adriel.lyricist group, it declares a minimum Android API of 21, and it carries a ktlint badge, which is consistent with a Kotlin project that keeps its own style rules in the build.

Runtime locale switching works, but persistence is yours to do

The dynamic part of the library is that the current locale can be changed at runtime, and the README says this triggers a recomposition that updates the entire content.

kotlin
lyricist.languageTag = Locales.PT

That is a single assignment rather than a configuration change and a restart, and because it flows through the CompositionLocal, everything that reads LocalStrings.current updates with it.

Two caveats follow, and the README states both plainly.

The first is the initial value. Lyricist uses the system locale as the current language, and on Compose it uses Locale.current. If your app has a mechanism to change the language in-app, you need to set the value yourself by passing currentLanguageTag when you call rememberStrings.

The second is persistence. If you change the current language at runtime, Lyricist will not persist the value to local storage by itself, and the README is explicit that this should be done by you, saving the current language tag on shared preferences, in a local database, or even through a remote API.

That second point is the one to think about before shipping. A language switch that resets on every app restart is a bug report waiting to happen, and the library has deliberately left that responsibility with the application, on the grounds that it cannot know whether your preference is device local, account scoped or server side.

The default and current tags can also both be supplied when you create the instance, with the default being the one annotated with @LyricistStrings(default = true) unless you override it, and the current one defaulting to the system locale.

Editorial conclusion

Lyricist suits a Compose Multiplatform team whose translations should be checked by the compiler rather than looked up by string key at runtime, since the strings become ordinary Kotlin values with a generated CompositionLocal, and the plural handling that Android forces into XML resources becomes a when expression in code. It does not suit a project that wants to keep maintaining strings.xml, because the XML processor is a migration path rather than a first class target, and the README states that it does not handle the few and many plural values. Before you adopt it, check the generated code visibility setting for your module, read the note about persistence of the runtime locale, which the library deliberately leaves to you, and note the version cadence, with 1.9.0 published on 2026-07-21 after 1.8.0 in December 2025.

Frequently asked questions

What is the Lyricist library for Compose?

It is an internationalization and localization library for Jetpack Compose and Compose Multiplatform that replaces string resources with generated Kotlin values, so parameterized strings are type safe, plurals are handled with a when expression, and the strings can be updated at runtime through an API.

How do I declare strings with Lyricist?

Declare them on a data class, class or interface, then annotate one instance per language with @LyricistStrings, providing a BCP47 compliant languageTag and marking one instance as the default. Lyricist then generates the LocalStrings CompositionLocal plus rememberStrings() and ProvideStrings().

Which platforms does Lyricist support?

It is multiplatform across Android, Desktop, iOS and Web with JsCanvas, and the repository contains sample applications for a plain Compose app, a multi-module app, a multiplatform app, a multiplatform iOS app and an XML project.

Does Lyricist handle Android plurals from strings.xml?

Only partly. The README lists as a limitation that the XML processor does not handle the few and many plural values, with pull requests welcome. In the Kotlin API plurals are ordinary functions, so the rules are code rather than resource entries.

How do I change the language at runtime with Lyricist?

Assign the language tag on the instance returned by rememberStrings(), for example lyricist.languageTag = Locales.PT, which triggers a recomposition. The README notes that the system locale is used as the initial value, that an in-app language switch must pass currentLanguageTag, and that persisting the choice is left to the application.

Official sources

  1. adrielcafe/lyricist on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/adrielcafe-lyricist.svg)](https://hysenlabs.com/projects/adrielcafe-lyricist)

Was this useful?