JavaRush /Courses /Kotlin SELF /Kotlin 2.x in a project: versions, compatibility, and upd...

Kotlin 2.x in a project: versions, compatibility, and update discipline

Kotlin SELF
Level 61, Lesson 3
Available

1. Version layers in a Kotlin project

When you hear “let’s update Kotlin to 2.x”, it is very tempting to imagine a single “Kotlin version” knob that you turn—and everything becomes modern and beautiful. In practice, Kotlin in a project lives in several layers at once: the compiler, the Gradle plugin, the standard library, dependencies, and the IDE. And any layer can break—and sometimes it breaks very politely: it just starts complaining with warnings that you will ignore, and six months later they will become errors.

To avoid confusion, let’s introduce a clear “map of the terrain”: which version lives where and what it is responsible for. The most useful skill is learning to separate: the language version (which syntax and rules are available), the compiler version (how it is analyzed and what it is turned into), and the build tools version (how the project is compiled in CI and on your machine).

Table: what versions exist in a Kotlin project

Layer What it is in plain language Where it is usually configured Typical symptoms of problems
Kotlin Gradle Plugin (KGP) version A plugin that adds Kotlin compilation tasks to Gradle build.gradle.kts (plugins { kotlin("jvm") version "…" }) The build does not start, Gradle tasks fail, weird configuration errors
Kotlin compiler version What actually compiles the code (including the K2 frontend in Kotlin 2.x) Comes together with KGP Code that “compiled yesterday” does not compile today; diagnostics change
Language/API version “Language rules” and the “API level” you are compatible with compilerOptions { languageVersion… apiVersion… } Unexpected feature restrictions/allowances; mismatched expectations between modules
Standard library (stdlib) Basic Kotlin functions/types Usually pulled in automatically by KGP, but can be pinned as a dependency Version conflicts, NoSuchMethodError at runtime (rare, but it happens)
Plugins and code generation (KSP, etc.) Tools that participate in compilation Gradle plugins + dependencies Generation stopped working, “incompatible version”, failure during the build stage
IDE + Kotlin plugin in the IDE What highlights code and runs builds from the button IntelliJ IDEA “Red in the IDE, but Gradle builds” or vice versa

This table is like a first-aid kit: it may look boring, but in a moment of pain it suddenly becomes your favorite.

2. K2, languageVersion, and update compatibility

What K2 is and why code turns “red” after an update

Kotlin 2.x is not only new language features, but also a big internal shift: a new compiler frontend appeared (it is often called K2). To oversimplify, the frontend is the part of the compiler that reads your code, understands its meaning, builds an internal representation, and decides: “this is allowed, and this is not”. When the frontend changes, diagnostics change too, and so does the interpretation of some “corner” cases—sometimes your code did not get worse; the compiler simply became stricter and more predictable.

An important thought for a beginner (and for a tired experienced developer as well): if new errors show up after updating Kotlin, it does not necessarily mean “they broke everything”. Sometimes it is the compiler that stopped tolerating what it used to silently swallow.

Example: open val and initialization in init

Here is an illustrative example from migrating to Kotlin 2.0: previously, some variants of deferred initialization “worked”, but now they do not. In Kotlin 2.0, open properties with a backing field must have an initializer immediately; otherwise, the compiler complains.

open class Base {
    open val a: Int
    open var b: Int

    init {
        this.a = 1 // In Kotlin 2.0: error, open val must have an initializer
        this.b = 1 // And this has always been an error for open var
    }
}

class Derived : Base() {
    override val a: Int = 2
    override var b: Int = 2
}

Why is this important specifically in a lecture about “versions”? Because this is a classic case: you updated Kotlin—and suddenly code that you have not touched in a long time “broke”. But in reality, the interpretation of a rule changed, and the project now requires more explicit guarantees.

How to set language rules for a project via languageVersion

When a project becomes bigger than a single folder “with lessons”, you start living in a reality where part of the code is written “the way we are used to”, part is written “the trendy way”, and part is written “as the IDE suggested at 2 a.m.”. And at this point, it becomes especially important to be able to manage compilation rules: sometimes you want to update the Gradle plugin and tools, but temporarily keep the old language rules so the migration goes more smoothly. Or the other way around: you want to enable newer rules where everything is already ready.

The key idea: the Kotlin plugin version and the language rules version are not the same thing. Yes, most of the time they move together, but sometimes it is useful to temporarily “pin” languageVersion.

Example: temporarily enable 1.9 rules

The official migration guide has a direct recipe: to use the previous compiler in Kotlin 2.0.0+ you can set languageVersion to 1.9 or pass -language-version 1.9.

The point of this step is not to “stay on 1.9 forever”. The point is to split the problem into chunks: first update the tools (Gradle, plugins), then enable the new language rules and address the errors.

Below is an example of how this might look in build.gradle.kts (simplified; the idea matters more than the exact place in the file):

import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

tasks.withType<KotlinCompile>().configureEach {
    compilerOptions {
        languageVersion.set(KotlinVersion.KOTLIN_1_9)
        apiVersion.set(KotlinVersion.KOTLIN_1_9)
    }
}

A switch like this is like a “temporary spare tire” mode. Not very fast, but it lets you make it to the service shop instead of fixing the wheel in the middle of the highway.

Why Kotlin 2.x often pulls a “train” of dependencies

There are two types of updates. The first type is “changed a number in one place and everything built”. The second type is “changed a number, and suddenly learned new words: ABI, frontend, binary compatibility, plugin API”. Kotlin 2.x more often belongs to the second type, especially if the project uses KSP, kotlinx.serialization, or other tools tied to compilation.

The most important habit to build is this: if a dependency participates in compilation (and not only at runtime), it is more sensitive to the Kotlin version. This applies to code generation, compiler plugins, and even how the IDE analyzes the project.

Version dependency diagram

flowchart TD
    A["Ver­si­on of Kotlin Gradle Plugin (KGP)"] --> B["Ver­si­on of Kotlin compiler (K2/K1)"]
    A --> C["Gradle tasks: compileKotlin, test, etc."]
    B --> D["Language rules (languageVersion/apiVersion)"]
    B --> E["Compilation tools: KSP/plugins"]
    E --> F["Generated .kt files"]
    F --> C

The idea is simple: if you updated Kotlin but forgot that you have KSP generating code, then you updated the “engine” but kept the old “carburetor”. The car (the build) may start coughing.

A practical compatibility principle

In the most human terms possible, the rule is: update the things related to compilation in a coordinated way. If you update Kotlin, look at:

  • Kotlin Gradle plugin (it is also what brings the compiler);
  • KSP (if present);
  • serialization plugins and other compile-time tools (if present);
  • and only after that—regular libraries.

What matters here is not “knowing all versions by heart”, but having discipline: update one layer—check the build, lock in the result, then move on.

3. Update discipline and compiler warnings

A step-by-step update scenario without “everything is on fire”

At some point you will start updating Kotlin not because “you want the newest thing”, but because the project needs a security patch, or a library stopped supporting an old version, or a colleague sent a PR saying “it works locally” (which is an ancient chaos incantation). And here, the winners are not those who change numbers faster, but those who make updates predictable.

What follows is not an “ideal 40-page process”, but a practical one for a small educational or pet project. And, conveniently, it scales: the same ideas work in large teams too.

First, you create a separate branch. It is banal, but it is the moment when you buy yourself the right to make mistakes. Then you update the Kotlin Gradle plugin (usually it is the version string in plugins { … }). After that, you immediately run the build from Gradle (not only from the IDE), because the IDE sometimes lives “in its own world”, while Gradle is the world where CI lives.

If the build failed, you do not run off fixing everything at once. You look: is it a Gradle configuration error (the build script did not compile), a Kotlin compilation error (code does not compile), or a test error (code compiles, but behavior changed). These are three different types of problems, and it helps to separate them—at least in your head.

Next, you do not enable new language rules immediately; you do it deliberately. If when moving to Kotlin 2.x the project in K2 mode starts complaining about many places, you can temporarily set languageVersion to 1.9, build the project, and then return to 2.x and fix the “pure” language issues rather than language + build at the same time. The fact that rollback is possible is described in the migration guide.

And finally, you lock in changes with small commits. This is not bureaucracy; it is help for your future self: “updated Kotlin”, “fixed X”, “fixed Y”. Then rollback and analysis become simple.

Why it is better not to “silence” warnings in Kotlin 2.x

The compiler has two modes of talking to you: “everything is fine” and “I warned you”. Warnings—especially deprecation warnings—are often perceived as noise: they get in the way, underline code, and spoil your mood. But in reality, they are one of the cheapest ways to learn about future problems ahead of time.

Kotlin and its ecosystem regularly promote warnings to errors as the language evolves. So the habit of “oh well, later” sometimes turns into “why is CI red if we did not change anything?”. The answer is usually: you did not change the code, but the world around it (versions) changed the rules.

A practical strategy here sounds boring, but it works: if a warning can be fixed in 5–10 minutes without losing meaning, fix it. If it cannot, then at least make the decision deliberate: leave a comment, create a task, or apply suppression (@Suppress(...)) as locally as possible. Otherwise, in six months you will look at suppressions like an archaeologist: “who did this, and why is a mammoth buried here?”.

4. Migrating your own API via @Deprecated

When you update Kotlin, a second layer of work almost always pops up: your own code evolves too. You rename functions, change contracts, remove “crutches”, and improve API design. And it is easy to hurt the users of your code (or yourself in a month): you renamed a method—and the whole application fell apart.

Kotlin provides a very convenient mechanism for gentle migration: @Deprecated. It is literally a road sign saying “roadworks—take the new street instead”. And the best part: the IDE can suggest the replacement automatically via ReplaceWith.

Example: migrating a function via ReplaceWith

Imagine that in our educational console app (let’s call it StudyTracker) we had a function formatMoneyOld(...), and we want to replace it with formatMoney(...), because the old name sounds like a confession of sins.

@Deprecated(
    message = "Use formatMoney(amountCents)",
    replaceWith = ReplaceWith("formatMoney(amountCents)")
)
fun formatMoneyOld(amountCents: Int): String = formatMoney(amountCents)

fun formatMoney(amountCents: Int): String {
    val dollars = amountCents / 100
    val cents = amountCents % 100
    return "$dollars.${cents.toString().padStart(2, '0')}"
}

What matters here is that we did not delete the old function instantly: we made it a thin wrapper so the project continues to build, and the IDE helps rewrite call sites.

Example: migrating a property via delegation

Sometimes you rename not a function but a property. Kotlin allows delegating one property to another, and this is explicitly recommended as a compatible renaming scenario: introduce a new property, mark the old one as @Deprecated, and delegate to the new one.

class Settings {
    var currencyCode: String = "USD"

    @Deprecated("Use 'currencyCode' instead", ReplaceWith("currencyCode"))
    var currency: String by this::currencyCode
}

fun main() {
    val s = Settings()
    s.currency = "EUR"
    println(s.currencyCode) // EUR
}

It looks almost like magic, but the meaning is very simple: the old name stays alive as an “alias”, while the data is actually stored in the new place.

5. Common mistakes when updating Kotlin to 2.x

Mistake #1: update Kotlin and twenty more dependencies “at the same time”, then hunt for the source of the breakage.
It is very tempting, because “if we suffer, let’s suffer all the way”. But it makes diagnosis almost impossible: you do not know whether it broke because of Kotlin, because of Gradle, because of a plugin, or because of a library. It is much more stable to update layer by layer and lock in small steps.

Mistake #2: confuse the “Kotlin version” with the “languageVersion”.
The Kotlin Gradle plugin version brings the compiler, but the language rules can be temporarily pinned. The migration guide explicitly mentions the rollback option to languageVersion 1.9 or -language-version 1.9 to use the previous compiler. This is useful as a temporary measure, but it is important to remember that it is a temporary bridge, not a “new normal”.

Mistake #3: ignore semantic changes and rules that the compiler started checking more strictly.
On Kotlin 2.x, you can suddenly get an error where previously you had “gray” code. The example with open val, which used to be able to initialize in init, but now requires an initializer, shows the nature of such changes well. It is not correct to be angry at the compiler—what is correct is to accept that the project needs more explicit guarantees.

Mistake #4: break your API “abruptly”, without gentle migration.
Delete the old function, rename the property, and hope “I will find everything with search”—that works until the first missed spot. Kotlin gives an excellent migration mechanism via @Deprecated and even via delegating the old property to the new one. If you do not use it, you are voluntarily giving up free help from the IDE and the compiler.

Mistake #5: treat warnings like garbage and silence them globally.
Warnings are often “an early version of a future error”. If you suppress them thoughtlessly, the project becomes fragile: it compiles “now”, but does not explain what exactly needs to be fixed before the next update. It is better to either fix them, or document why suppression is justified, and keep it local.

1
Task
Kotlin SELF, level 61, lesson 3
Locked
Initialization fix
Initialization fix
1
Task
Kotlin SELF, level 61, lesson 3
Locked
Renaming a property
Renaming a property
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION