Connection · Interrupted

Something didn't load

Part of this page failed to reach you. Reload to try again — if it keeps happening, check your connection.

Skip to main content
Engineering9 min read

Kotlin 2.4: The Three Changes That Moved My Hand on the Keyboard

Kotlin 2.4.0 shipped a long changelog, but only three features changed how I actually type: stable context parameters, explicit backing fields, and (still behind a flag) name-based destructuring. Here is my backend-engineer's cut, verified against the 2.4.0 compiler, plus the K1 removal I had to put on a calendar.

All Posts
2/4

Kotlin 2.4.0 shipped this month, and the release notes are long: stable context parameters, explicit backing fields, annotation use-site targets, a stable UUID API, sorted-order checks, Java 26 support, and the removal of the K1 compiler, among others. I read the whole list. Then I went back through a few of my own files and rewrote them against 2.4 to see which lines actually changed.

The honest answer: three features changed how I type, and one removal changed how I scheduled my week. Everything else is a footnote I will appreciate the day I hit it. This is the opinionated cut, written from a backend engineer's chair rather than an Android one.

Context parameters: threading the logger without threading the logger

This is the one I will reach for first. Context parameters went Stable in 2.4 (tracker issue KT-72222), replacing the older context receivers experiment.

The problem they solve is the dependency that every function in a call path needs but nobody wants in the signature. A request-scoped logger is the cleanest example. A clock, a transaction handle, or a tenant id work the same way. You either thread the value through every function as an argument it does not really use, or you reach for a thread-local and lose all type safety.

A context parameter is a third option: the function declares it needs a value of some type from its surroundings, and the compiler supplies it at the call site without you writing the argument.

Here is a self-contained file. It compiles and runs on Kotlin 2.4.0 with no flags.

kotlin
import java.util.UUID

// One thing every handler needs but nobody wants in the signature:
// a request-scoped logger that tags lines with the request id.
class RequestLog(val requestId: UUID) {
    fun info(message: String) = println("[$requestId] $message")
}

data class Order(val id: String, val cents: Long)

// The context parameter says: "I need a RequestLog from my surroundings."
// Callers never pass it explicitly; the compiler resolves it by type.
context(log: RequestLog)
fun charge(order: Order): Long {
    log.info("charging order ${order.id} for ${order.cents}c")
    return order.cents
}

context(log: RequestLog)
fun refund(order: Order): Long {
    log.info("refunding order ${order.id}")
    return -order.cents
}

// A single `with(log)` opens the scope; everything inside sees the logger.
fun handleRequest(order: Order) {
    val log = RequestLog(UUID.nameUUIDFromBytes("req-42".toByteArray()))
    with(log) {
        val net = charge(order) + refund(order)
        log.info("net effect: ${net}c")
    }
}

fun main() {
    handleRequest(Order("A-100", 2599))
}

Run it with kotlinc orders.kt -include-runtime -d orders.jar && java -jar orders.jar. It prints three lines, each tagged with the same request id, and charge and refund never named the logger in their call.

The line worth studying is context(log: RequestLog). The log name makes the dependency referable inside the body, which the unnamed context receivers could not do cleanly. At the call site, charge(order) carries no logger; the with(log) block put a RequestLog into scope, and the compiler matched it by type.

That matching is the part to understand before you commit to the feature. From my reading of the FIR resolution code, context resolution is a real type-directed scope search, not a syntactic trick. For each context parameter, the compiler counts the matching values in scope and decides by count.

The diagram above is the whole mental model. Zero values of the right type in scope, and the call reports NoContextArgument and does not compile. Exactly one, and it is passed. Two or more, and you get AmbiguousContextArgument — the compiler refuses to guess between two equally valid loggers. This is the safety rail: an implicit dependency that is missing or ambiguous is a compile error, never a silent runtime choice.

The trade-off is readability, and it is real. A reader scanning charge(order) cannot see that a logger is flowing in. You have traded an explicit-but-noisy argument for an invisible-but-quiet one. My rule from rewriting a handler this way: context parameters earn their keep for cross-cutting values that genuinely thread through a deep call graph — a logger, a clock, a transaction. The moment two values of the same type can coexist in scope, you are one refactor away from an ambiguity error, and the implicitness stops paying for itself.

One caveat for 2.4 specifically. Naming the argument at the call site, like charge(log = primary), is still experimental behind -Xexplicit-context-arguments. The language feature that turns it on by default is slated for 2.5. So in 2.4 you get the stable implicit form, but you cannot yet disambiguate by name without the opt-in flag.

Explicit backing fields retire the private-shadow property

The second change I keep using is explicit backing fields, also Stable in 2.4. The tracker number, KT-14663, is one of the oldest open requests in Kotlin, which tells you how long the pattern it kills has been around.

The pattern is the property you expose as a read-only type while its backing field holds a wider, mutable type. On Android this is the _state / state StateFlow pair, but the shape shows up in plenty of backend code: a metrics buffer, an accumulating list, anything mutable internally and read-only to callers. The old way needs two declarations:

kotlin
private val _items = mutableListOf<Int>()
val items: List<Int> get() = _items

Two names for one piece of state, only because the inside type and the outside type differ. Explicit backing fields collapse that into a single property:

kotlin
class Counter {
    val total: List<Int>
        field = mutableListOf<Int>()

    fun add(n: Int) { total.add(n) }   // inside: MutableList<Int>
}

I compiled and ran exactly this on 2.4.0. Inside the class, total resolves to MutableList<Int>, so total.add(n) works. Outside, total is List<Int> and exposes no mutation. One property, two visibilities of the same object, no _-prefixed shadow.

The restrictions are worth knowing before you reach for it. From my reading of the checker, an explicit backing field is allowed only on a final val, never on a var, an open property, or an interface, abstract, expect, or extension property. The backing field's visibility must be more restrictive than the property's. The logic is consistency: if the property could be overridden, a subclass could supply a different field type, and a caller could no longer reason about which type it holds. Final-val-only removes that ambiguity. For backend code this lands less often than for an Android ViewModel, but every time I have a "mutable inside, immutable outside" property, the shadow is now gone.

Name-based destructuring, still behind a flag

The third change has not shipped as stable, and I want to be precise about that because it is easy to misread the release coverage. Name-based destructuring is experimental in 2.4, gated behind -Xname-based-destructuring. I am including it because it already changed how I write new data-class consumers in branches I am willing to flag-gate, and because it fixes a bug class I have hit for real.

Positional destructuring binds by order, not by name. The classic trap:

kotlin
data class Person(val name: String, val age: Int)
val (age, name) = person   // compiles, both are wrong

That compiles. age gets the name and name gets the age, because position is all the compiler uses. Add a field in the middle of the data class later, and every positional destructure downstream silently shifts.

Name-based destructuring binds by property name instead. I verified this on 2.4.0 with -Xname-based-destructuring=only-syntax:

kotlin
(val age, val name) = person   // binds by name, order irrelevant

Here age gets person.age and name gets person.name, regardless of the order I wrote them or the order the data class declares. The bytecode is unchanged; this is a use-site feature. The flag has three modes worth knowing: only-syntax enables the new parenthesized form, name-mismatch warns when an old positional destructure uses names that do not match the properties, and complete turns on the short form. JetBrains has signposted stabilization for a later release.

I am not putting this in main yet. But name-mismatch mode alone is worth a CI experiment: it surfaces every existing positional destructure where the variable names already lie about which field they hold.

The K1 removal I had to schedule

The breaking change in 2.4 is structural: the K1 compiler frontend is gone. Since 2.0, K2 has been the default, but K1 lingered behind -language-version 1.9 for projects that needed it. In 2.4 that escape hatch is removed. The compiler now rejects -language-version 1.9 outright; the lowest language version it accepts is 2.0.

If your build still pins languageVersion = "1.9" anywhere — a Gradle convention, a stubborn module, a third-party plugin's defaults — 2.4 will not compile it. That is the line item I had to put on a calendar rather than absorb on upgrade day. The fix is not hard, but it is not automatic: find every pinned language version, drop it to at least 2.0, and rebuild against K2's stricter resolution, which catches a few constructs K1 let slide.

For Kotlin Multiplatform users there is a paired change: partial linkage is now permanently on. The -Xpartial-linkage flag is deprecated, and only -Xpartial-linkage-loglevel survives to control how loud a degraded link is. If you depended on turning partial linkage off, that switch is gone.

The rest of the 2.4 standard-library additions are the footnotes I mentioned. isSorted, isSortedBy, and isSortedDescending arrive across iterables, arrays, and sequences; they short-circuit at the first out-of-order pair instead of scanning the whole collection. The kotlin.uuid.Uuid type is now stable, so parsing and formatting UUIDs needs no opt-in — though generateV4 and generateV7 for minting new ones are still experimental. UInt.toBigInteger() and ULong.toBigInteger() replace the old string-based conversions on the JVM. Good to have, none of them changed a line I had already written.

What to actually do with this

  • Reach for context parameters for a cross-cutting value that threads through a deep call graph — logger, clock, transaction. Stop the moment two values of the same type could share a scope, or you will trade quiet code for an AmbiguousContextArgument error.
  • Use explicit backing fields wherever you have a final val that is mutable inside and read-only outside. Delete the _-prefixed shadow property. Remember it is final-val-only.
  • Try name-based destructuring in name-mismatch mode in CI before adopting it. It flags every positional destructure whose variable names already disagree with the data class.
  • Before upgrading, grep for languageVersion = "1.9" across your build and drop it to 2.0+. K1 is gone; a pinned 1.9 fails the build.

When to avoid each: skip context parameters for one-off dependencies a single function needs — a plain argument is clearer. Skip explicit backing fields on var or open properties; the compiler will not let you anyway. Keep name-based destructuring out of main until it stabilizes. And do not treat the K1 removal as optional — it is the one change in 2.4 that fails closed.

Read next

Still here? You might enjoy this.

Nothing close enough — try a different angle?

Was this helpful?

Leave a rating or a quick note — it helps me improve.