← All writings

Context Parameters Are Finally Stable — and They'll Change How You Write Kotlin

If you’ve been writing Kotlin for a while, you’ve heard whispers of this feature under three different names. It started life as “multiple receivers,” became the experimental context receivers that lived behind a compiler flag for years, got redesigned into context parameters in the Kotlin 2.2 preview, and now — with Kotlin 2.4.0, released in June 2026 — it’s finally stable. No compiler flags. No “this API is subject to change” warnings. Just a new tool in the language, shipping in the Kotlin version already bundled with the latest Android Studio.

This is a big deal, and not just because of how long the wait was. Context parameters solve one of the most persistent annoyances in real-world Kotlin codebases: threading ambient dependencies through layers of code that don’t care about them.

Let’s dig in.

The problem: parameter drilling

Every mobile codebase has some version of this. You have a logger, an analytics tracker, a transaction handle, or a coroutine-scoped resource, and it needs to be available deep in a call chain, even though most of the functions in that chain never touch it:

fun syncAccount(account: Account, logger: Logger) {
    refreshTokens(account, logger)
    pullRemoteChanges(account, logger)
    pushLocalChanges(account, logger)
}

fun pullRemoteChanges(account: Account, logger: Logger) {
    val changes = fetchChanges(account, logger)   // just passing it along...
    applyChanges(changes, logger)                  // ...and along...
}

The logger parameter is noise. It’s not part of what these functions do — it’s part of the environment they run in. React developers call this “prop drilling.” Kotlin developers have historically had three escape hatches, all bad in different ways:

  1. Singletons / globalsLog.d() everywhere. Convenient, untestable, hidden coupling.
  2. Extension receiversfun Logger.pullRemoteChanges(...). Works, but you only get one receiver, and you’ve now made Logger the grammatical subject of a function that has nothing to do with logging. logger.pullRemoteChanges(account) reads like nonsense.
  3. Wrapper classes — inject everything through a constructor and make every function a method. Fine, but now everything is a class, and composing two “environments” means another wrapper.

Context parameters are the fourth option, and it’s the one the language should have had all along.

What context parameters look like

A context parameter is declared before the function, with the context keyword. It’s a named parameter, but the caller never passes it explicitly — the compiler finds it from the surrounding scope:

interface Logger {
    fun log(message: String)
}

context(logger: Logger)
fun pullRemoteChanges(account: Account) {
    logger.log("Pulling changes for ${account.id}")
    // ...
}

Two things to notice:

  • The function’s signature stays honest: pullRemoteChanges(account: Account) takes an account. The logger is declared as context — an ambient requirement, not an input.
  • Unlike the old context receivers, the context parameter has a name (logger). You access it explicitly as logger.log(...), not through a magic implicit this. This was the single biggest redesign between the experimental and stable versions, and it’s what makes the feature readable in code review.

Supplying the context

At the call site, the compiler resolves context arguments from whatever is in scope — including implicit receivers. The simplest bridge is one you already know: with.

val logger = TimberLogger()

with(logger) {
    pullRemoteChanges(account)   // logger resolved automatically
}

And here’s where it gets nice: a context-declaring function can call other context-declaring functions without any ceremony, because its own context parameter satisfies theirs:

context(logger: Logger)
fun syncAccount(account: Account) {
    logger.log("Sync started")
    refreshTokens(account)      // context flows through implicitly
    pullRemoteChanges(account)  // no logger argument anywhere
    pushLocalChanges(account)
}

The drilling is gone. The dependency is declared exactly once per function that requires it, invisible in the functions that merely forward it — because they don’t have to forward it at all.

If a function needs the context only to pass it along and never touches it directly, you can even make that explicit with an anonymous context parameter:

context(_: Logger)
fun refreshTokens(account: Account) {
    validateSession(account)  // needs a Logger; gets ours
}

Real-world patterns for mobile developers

1. Transaction scoping

This is the classic. Repository functions that must run inside a database transaction can now say so in their signature, and the compiler enforces it:

interface Transaction {
    fun execute(sql: String, vararg args: Any?)
}

context(tx: Transaction)
fun insertMessage(message: Message) {
    tx.execute("INSERT INTO messages (id, body) VALUES (?, ?)", message.id, message.body)
}

context(tx: Transaction)
fun markThreadUpdated(threadId: String) {
    tx.execute("UPDATE threads SET updated_at = ? WHERE id = ?", now(), threadId)
}

// The only way to call these is inside a transaction:
db.withTransaction { // this: Transaction
    insertMessage(message)
    markThreadUpdated(message.threadId)
}

Try to call insertMessage outside a transaction block and you get a compile error, not a runtime crash at 2 a.m. This pattern — “you can only call this function in the right scope” — is the deepest value of the feature. It turns runtime discipline into type checking.

2. Typed error handling (the Arrow pattern)

The Arrow team has been building toward this for years with their Raise DSL, and context parameters make it first-class. A function that can fail declares what it can fail with:

sealed interface CheckoutError {
    data object EmptyCart : CheckoutError
    data class PaymentDeclined(val reason: String) : CheckoutError
}

context(raise: Raise<CheckoutError>)
fun validateCart(cart: Cart): ValidatedCart {
    if (cart.items.isEmpty()) raise.raise(CheckoutError.EmptyCart)
    return ValidatedCart(cart.items)
}

context(raise: Raise<CheckoutError>)
fun checkout(cart: Cart): Receipt {
    val validated = validateCart(cart)   // errors propagate through context
    return submitPayment(validated)
}

// At the boundary, decide how to handle failure:
val result: Either<CheckoutError, Receipt> = either { checkout(cart) }

You get exception-like ergonomics (errors short-circuit, no .flatMap chains, no Result wrapping on every line) with Either-like safety (the error type is in the signature, exhaustively handled at the edge). For ViewModels that currently juggle Result<T> or throw and pray, this is a genuinely better architecture — and it’s now built on a stable language feature.

3. Ambient app services — without the god object

Instead of a Logger alone, define small capability interfaces and compose them per call site:

context(analytics: Analytics, logger: Logger)
fun completeOnboarding(user: User) {
    logger.log("Onboarding complete for ${user.id}")
    analytics.track("onboarding_complete")
}

Multiple context parameters are allowed, which is exactly what extension receivers could never give you. In tests, you supply fakes with with(FakeAnalytics()) { with(FakeLogger()) { ... } } — or a small helper that provides both. No DI framework gymnastics, no @Inject on a free function.

4. Playing nicely with Compose

Jetpack Compose already has its own ambient-value system — CompositionLocal — and the two coexist cleanly. CompositionLocal carries values through the composition; context parameters carry them through plain Kotlin call chains. The bridge is one line:

@Composable
fun CheckoutButton(cart: Cart) {
    val analytics = LocalAnalytics.current
    Button(onClick = {
        with(analytics) { trackCheckoutTapped(cart) }  // enter context world
    }) {
        Text("Check out")
    }
}

If you’ve ever wished you could use something like CompositionLocal in your domain layer without depending on Compose — that’s exactly what this is.

The gotchas (please read this part)

Stable doesn’t mean “use everywhere.” A few rules of thumb, aligned with JetBrains’ own guidance:

Context parameters are for context, not inputs. If a value is what the function is fundamentally about — the order being placed, the user being updated — it’s a regular parameter. Context is for the ambient stuff: transactions, tracing, loggers, error channels, resource scopes. If you find yourself writing context(order: Order), stop.

Implicitness has a cost. The call site pullRemoteChanges(account) no longer shows that a Logger is involved. The IDE shows it, the signature shows it, but a plain GitHub diff doesn’t. Keep context parameter lists short (one or two), and prefer meaningful capability interfaces (Raise<CheckoutError>) over grab-bags (AppServices).

Java interop is mechanical but ugly. Compiled context parameters become leading regular parameters of the JVM method. Fine for pure-Kotlin modules; think twice on API surfaces consumed from Java.

Migrating from context receivers? The old -Xcontext-receivers experiment is deprecated. The migration is mostly renaming: give each receiver a name, then replace bare member calls (log(...)) with qualified ones (logger.log(...)). Your code gets slightly longer and significantly clearer.

The rest of the “right now” picture

Context parameters are the headline, but mid-2026 is a genuinely busy moment in Kotlin mobile, and a few other things deserve your attention:

  • Swift packages as dependencies in Kotlin/Native (also in 2.4.0). KMP teams can now depend on Swift packages directly, which removes one of the last big friction points in sharing code with iOS — no more wrapping everything in Objective-C-compatible shims by hand.
  • Compose Multiplatform 1.11.0 shipped experimental native text input on iOS — real UIKit-grade caret behavior, selection handles, and the system context menu (including Autofill) inside Compose UIs. Text input has long been the “you can tell it’s not native” giveaway on iOS; this closes most of that gap. Concurrent rendering is also now on by default.
  • Compose Hot Reload 1.2.0 is experimenting with an MCP server, letting AI coding agents inspect and interact with your running Compose app — restart it, read UI errors, drive lifecycle. Worth watching if agentic tooling is part of your workflow.
  • Amper, JetBrains’ simplified build tool, has graduated into the official Kotlin Toolchain as a JetBrains-supported Alpha, alongside a unified kotlin CLI command.

Try it today

There’s nothing to enable. Bump your Kotlin version and go:

plugins {
    kotlin("android") version "2.4.0"
}

The latest stable Android Studio and IntelliJ IDEA already bundle 2.4.0 support, with full IDE resolution, quick-fixes, and inlay hints showing which context arguments are being filled in at each call site.

My suggestion for a first real use: find the one dependency your codebase passes around the most — it’s almost always a logger, an analytics tracker, or a transaction handle — and convert that single call chain. You’ll know within an hour whether this feature earns a permanent place in your team’s style guide. Seven years of design iteration says it will.


Sources: Kotlin 2.4.0 Released (JetBrains Blog) · Compose Multiplatform 1.11.0 (JetBrains Blog) · Kotlin release process (kotlinlang.org)