Engineering

The Compose compiler version is not independent of your Kotlin version

A Kotlin bump without the matching plugin fails at a position no trace explains.

MobileByteSensei 9 min read
kmpcomposebuild

TL;DR

  • A routine Kotlin 1.9.24 → 2.0.20 bump failed with 17 errors in generated code, every one of them pointing at a line number in a file nobody wrote, and none of them mentioning Compose.
  • The Compose compiler plugin reads and rewrites Kotlin IR. IR is not a stable interface between Kotlin versions, so a plugin built against one compiler cannot be loaded by another.
  • The lockstep is real and it is not symmetric: a Kotlin bump requires a plugin bump, and a plugin bump often permits several Kotlin versions. The catalog is where the pair belongs.
  • What reading on buys: how to read the error, the one-line Gradle check that turns it into a legible message, and why kotlinCompilerExtensionVersion disappeared from the build file.

The case this post is about

A five-target module, Kotlin bumped in the version catalog and nothing else touched:

# gradle/libs.versions.toml
[versions]
-kotlin = "1.9.24"
+kotlin = "2.0.20"
compose-compiler = "1.5.14"
// shared/build.gradle.kts — unchanged
composeOptions {
    kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
$ ./gradlew :shared:compileKotlinAndroid

e: file:///shared/build/generated/.../ComposableSingletons$ProfileScreenKt.class
   Unresolved reference: ComposableLambdaImpl
e: file:///shared/build/generated/.../ProfileScreenKt.class:412:18
   Type mismatch: inferred type is Composer? but Composer was expected
… 17 errors

Seventeen errors in build/generated, at line numbers in files the build had just produced.

The symptom

The errors are the unhelpful part. The informative line is three hundred lines earlier in the output, and it is a warning:

w: Compose compiler plugin 1.5.14 was built against Kotlin 1.9.x and may not
   be compatible with Kotlin 2.0.20. Set a matching version.

A warning, in a build that produces two hundred lines of output, followed by seventeen errors that say nothing about Compose. The errors are what you see; the warning is what happened.

The second symptom was worse because it did not fail. One module in the same build had no Compose code, compiled fine, and shipped — so the build was partially green, and the first hypothesis was that something about the failing modules was special.

Four combinations of Kotlin and Compose compiler versions showing that bumping the plugin patch version changes nothing, which rules out an old plugin and rules in a plugin built for a different Kotlin line

Why the obvious fix failed

The first attempt was to bump the Compose compiler to its newest release and hope the pair lined up:

compose-compiler = "1.5.15"
w: Compose compiler plugin 1.5.15 was built against Kotlin 1.9.x …

Same warning. The 1.5.x line is built against Kotlin 1.9, and no patch release of it will ever target 2.0 — the versioning does not work the way the version number suggests.

The second attempt was suppressKotlinVersionCompatibilityCheck, which is offered and is a trap:

freeCompilerArgs += listOf(
    "-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=2.0.20"
)

That suppresses the warning and not the incompatibility. The build then proceeded further and failed differently — in the IR lowering phase rather than at compilation — with an internal compiler error and no line number at all:

e: org.jetbrains.kotlin.backend.common.CompilationException:
   Back-end: Please report this problem

A worse error, arrived at deliberately. The flag exists for the narrow case where you have verified compatibility yourself against a pre-release pair, and using it to get past a legitimate mismatch converts a clear failure into an unclear one.

The third attempt was correct and only half the story: Kotlin 2.0 moved the Compose compiler into the Kotlin repository, so it is no longer a separately-versioned artifact at all.

The actual mechanism

The Compose compiler is a Kotlin compiler plugin, and it operates on Kotlin's intermediate representation.

It rewrites IR rather than source. Every @Composable function is transformed — a Composer parameter is threaded through, a changed-bitmask is added, lambdas are lifted into singletons, restart groups are inserted. That transformation is written against the IR classes of a specific Kotlin version.

Kotlin's IR is not a stable published interface. It changes between versions, including minor ones, and it is explicitly not part of Kotlin's compatibility guarantee. So a plugin compiled against 1.9's IR classes encounters a different shape in 2.0.

Which is why the errors land in generated code. The plugin ran, produced output using the structure it expected, and the compiler then type-checked that output against the structure it actually has. The errors are real type errors in real generated code, which is why they look like your bug and mention nothing about plugins.

And the relationship is not symmetric. One Compose compiler release supports exactly one Kotlin minor line, and one Kotlin version may be supported by several plugin patch releases. So "bump the plugin to its latest" is not a strategy — the question is which plugin release was built for this Kotlin.

From Kotlin 2.0 the plugin ships in the Kotlin distribution, applied as a Gradle plugin rather than configured as a version string. The lockstep did not go away; it became impossible to express incorrectly, which is the better fix and is why the migration is worth doing rather than versioning around.

The Compose compiler as a Kotlin compiler plugin that rewrites intermediate representation, which is not a stable interface across versions, so a plugin built against one compiler produces output the next one type-checks and rejects

The fix

Apply the Compose compiler as a Gradle plugin from the Kotlin version, and delete the version string that can disagree.

# gradle/libs.versions.toml
[versions]
kotlin = "2.0.20"
# compose-compiler — DELETED. From Kotlin 2.0 the plugin ships with the compiler, so there is
# no second version to keep in step and therefore no way to get the pair wrong.

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
compose-compiler     = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
#                        ↑ version.ref = kotlin, not its own version. The lockstep as a
#                          declaration rather than as a thing to remember.
// shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.compose.compiler)      // replaces composeOptions entirely
}

// composeOptions { kotlinCompilerExtensionVersion = … }  — DELETED.

And the guard for the pre-2.0 half of a fleet, because a monorepo migrates module by module and the mismatch is silent in the modules that have not moved yet:

// build-logic/src/main/kotlin/ComposeLockstepCheck.kt
//
// Fails CONFIGURATION with a message that names the mechanism, rather than letting the build
// reach IR lowering and emit type errors in generated code. The whole value here is the error
// text: the real failure is legible only if you already know the plugin rewrites IR.
abstract class ComposeLockstepCheck : DefaultTask() {

    @get:Input abstract val kotlinVersion: Property<String>
    /** Absent once the module has migrated to the bundled plugin — which is the goal. */
    @get:Input @get:Optional abstract val extensionVersion: Property<String>

    @TaskAction
    fun check() {
        val ext = extensionVersion.orNull ?: return      // migrated: nothing to disagree
        val kotlin = kotlinVersion.get()

        // The pairing is published per release; it is not derivable from the numbers, which is
        // itself the point — 1.5.15 looks newer than 1.5.14 and targets the same Kotlin line.
        val supported = COMPOSE_TO_KOTLIN[ext]
            ?: throw GradleException(
                "Compose compiler $ext is not in the known compatibility table.\n" +
                "  Either it is newer than this check, or it does not exist.\n" +
                "  Preferred fix: delete kotlinCompilerExtensionVersion and apply\n" +
                "  org.jetbrains.kotlin.plugin.compose at version $kotlin."
            )

        val kotlinMinor = kotlin.substringBeforeLast('.')
        if (kotlinMinor !in supported) {
            throw GradleException(
                "Compose compiler $ext targets Kotlin ${supported.joinToString("/")}, " +
                "but this build uses $kotlin.\n" +
                "  The plugin rewrites Kotlin IR, which is not stable across versions, so the\n" +
                "  failure surfaces as type errors in build/generated rather than here.\n" +
                "  Fix: apply org.jetbrains.kotlin.plugin.compose at version $kotlin and remove\n" +
                "  kotlinCompilerExtensionVersion.\n" +
                "  Do NOT pass suppressKotlinVersionCompatibilityCheck: it hides the warning,\n" +
                "  not the incompatibility, and the build then fails in IR lowering instead."
            )
        }
    }

    private companion object {
        val COMPOSE_TO_KOTLIN = mapOf(
            "1.5.14" to listOf("1.9"),
            "1.5.15" to listOf("1.9"),
        )
    }
}

And the output it produced, run against the module as it stood before the migration:

$ ./gradlew :shared:checkComposeLockstep

FAILURE: Build failed with an exception.
* What went wrong:
Compose compiler 1.5.14 targets Kotlin 1.9, but this build uses 2.0.20.
  The plugin rewrites Kotlin IR, which is not stable across versions, so the
  failure surfaces as type errors in build/generated rather than here.
  Fix: apply org.jetbrains.kotlin.plugin.compose at version 2.0.20 and remove
  kotlinCompilerExtensionVersion.
  Do NOT pass suppressKotlinVersionCompatibilityCheck: it hides the warning,
  not the incompatibility, and the build then fails in IR lowering instead.

One failure, before compilation, naming the fix — against seventeen type errors in generated code.

How we proved it

That the errors were a consequence and the warning was the cause — established by reverting only the Kotlin version and counting:

                                        warning   errors in build/generated
kotlin 1.9.24 + compose 1.5.14              no            0
kotlin 2.0.20 + compose 1.5.14             YES           17
kotlin 2.0.20 + compose 1.5.15             YES           17
kotlin 2.0.20 + bundled plugin              no            0

Bumping the plugin patch version changed nothing, which is what rules out "an old plugin" and rules in "a plugin for a different Kotlin line".

That suppressing the check makes the failure worse. Measured, because it is the flag the warning's own text invites you toward:

                                            failure stage          line number in the error?
default                                     compilation            yes (generated file)
+ suppressKotlinVersionCompatibilityCheck   IR lowering            no — internal error

A clear failure traded for an unclear one.

That the module with no Compose code was not special. The partial-green build sent the first hour of the investigation after the wrong question:

$ ./gradlew build 2>&1 | grep -E 'Task.*(compileKotlin|FAILED)' | head
> Task :core:model:compileKotlinAndroid                  SUCCESS   # no @Composable anywhere
> Task :core:designsystem:compileKotlinAndroid FAILED
> Task :feature:profile:compileKotlinAndroid   FAILED

Every failing module contained @Composable; every succeeding one did not. The plugin only transforms what it recognises, so a module with nothing to transform is unaffected by a transformation bug — which reads as "those modules are fine" and means "those modules were never asked".

That the check fires before anything expensive. A deliberately-mismatched fixture, run to confirm the failure lands at configuration rather than after a two-minute compile:

$ ./gradlew :fixtures:compose-mismatch:build
FAILURE in 1s — Compose compiler 1.5.14 targets Kotlin 1.9, but this build uses 2.0.20.

One second, against the 94 seconds the real failure took to reach seventeen errors.

What I cannot keep current. COMPOSE_TO_KOTLIN is a hand-maintained table and it will go stale — which is why the unknown-version branch tells you to migrate rather than guessing. The table exists for modules that have not yet moved to the bundled plugin, and the correct lifetime of this whole check is "until every module has". We deleted it from four of eleven modules in the first week and the remaining seven are tracked.

And the limit of the diagnosis. This explains a version mismatch. It does not cover a plugin that is correctly paired and still miscompiles — which happens, rarely, and looks identical from the build output. The distinguishing test is whether the bundled plugin at the exact Kotlin version reproduces it; if it does, the table is not your problem.

Two measurements separating the symptom from the cause: suppressing the compatibility check moves the failure from compilation to intermediate-representation lowering and removes the line number, and every failing module contained composable code while every succeeding one did not

What this generalizes to

A plugin that operates on a compiler's internal representation is coupled to that compiler's version in a way its own version number does not express, and the failure surfaces in generated output rather than at the coupling. The reliable habit when a build fails inside build/generated is to read upward for a warning rather than outward from the error — the error is downstream of whatever ran and the warning is the thing that ran. And where a toolchain offers to bundle a coupled pair, taking the bundle is worth a migration: it removes a class of mistake rather than documenting it.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS