Inside Kotlin 2.4: Context Parameters, Explicit Backing Fields, and the End of K1

skydovesJaewoong Eum (skydoves)||14 min read

Inside Kotlin 2.4: Context Parameters, Explicit Backing Fields, and the End of K1

Kotlin 2.4 is a release with two faces. On the surface it promotes two language features that have been incubating for years, context parameters and explicit backing fields, to stable. Underneath, it removes the last support for the old K1 frontend, leaving K2 as the only compiler your code ever passes through. Most release roundups stop at the syntax, but the more interesting question is how these features are represented inside the compiler and what the K2 only baseline changes about compilation and linking.

In this article, you'll explore the structural changes that make 2.4 a K2 only release, how explicit backing fields collapse the two property pattern that Android developers write every day, how context parameters are represented and resolved inside the FIR frontend, how collection literals desugar into an of operator call, and a set of standard library additions worth knowing. The goal is to understand how each feature works, not just how to type it.

The end of K1: a K2 only compiler

The headline structural change in 2.4 is that the K1 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. You can see the new baseline encoded directly in the LanguageVersion enum companion object inside LanguageVersionSettings.kt:

val FIRST_API_SUPPORTED = KOTLIN_2_0
val FIRST_SUPPORTED = KOTLIN_2_0
val FIRST_NON_DEPRECATED = KOTLIN_2_2
val LATEST_STABLE = KOTLIN_2_4

FIRST_SUPPORTED = KOTLIN_2_0 means every 1.x language version is now unsupported rather than merely deprecated. A version's isUnsupported property returns true for anything below FIRST_SUPPORTED, so the compiler rejects -language-version 1.9 outright. Versions 2.0 and 2.1 sit in the deprecated band between FIRST_SUPPORTED and FIRST_NON_DEPRECATED, and 2.2 onward are the non deprecated, stable set.

The enum also exposes a property that captures the whole story in one line:

val usesK2: Boolean
    get() = this >= KOTLIN_2_0

Because the lowest supported version is already 2.0, usesK2 is now true for every value the compiler will accept. There is no longer any reachable configuration that runs the K1 frontend. That is what "K2 only" means in practice: not a default, but the absence of an alternative.

Partial linkage is always on

Removing K1 is paired with a second simplification aimed at Kotlin Multiplatform: partial linkage is now permanently enabled. Partial linkage is the mechanism that lets a multiplatform binary still link when a dependency has changed its ABI, for example when a library you depend on removed a function that another library still references. Instead of failing the whole compilation, the linker replaces the broken reference with a stub that throws if it is ever actually called.

In 2.4 there is no longer a switch to turn this off. Looking at the compiler argument definition in CommonKlibBasedCompilerArguments.kt, the -Xpartial-linkage flag is marked deprecated as of 2.4.0, with a description that states the engine is always on. What remains is the ability to tune how loud it is:

name = "Xpartial-linkage-loglevel"
description = "Define the compile-time log level for partial linkage."
valueDescription = "{silent|info|warning|error}"

You keep -Xpartial-linkage-loglevel to choose whether a degraded link is silent, informational, a warning, or an error, but the engine itself is no longer optional.

Intra module inlining moves into klib compilation

The third structural change is about when inline functions are actually inlined for non JVM targets. In 2.4, inline functions declared in the same module are inlined during klib compilation rather than during final binary generation. This is encoded as a language feature rather than a loose flag:

IrIntraModuleInlinerBeforeKlibSerialization(KOTLIN_2_4, sinceApiVersion = ApiVersion.KOTLIN_2_3, issue = "KT-79717"),

The behavior is controlled by -Xklib-ir-inliner, which accepts intra-module, full, disabled, and default. The intra-module mode inlines only functions from the module being compiled and is safe for published libraries. The full mode also inlines functions from dependencies, but the compiler warns that this marks the produced library as pre-release, because inlining a dependency's body freezes a copy of it into your artifact. Moving intra module inlining earlier makes the result consistent across the JVM and Kotlin/Native or Kotlin/Wasm pipelines, which previously inlined at different stages.

Explicit backing fields

Of all the language changes in 2.4, explicit backing fields is the one Android developers will reach for first. The feature lets a property expose one type to the outside world while its backing field holds a more specific type, and it is registered as a stable language feature in 2.4:

ExplicitBackingFields(sinceVersion = KOTLIN_2_4, issue = "KT-14663"),

The KT number is telling. KT-14663 is one of the oldest requests in the tracker, and it exists because of a pattern every Android codebase repeats. To expose a read only stream while keeping a mutable one private, you write two properties:

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun update() {
        _state.value = _state.value.copy(loading = true)
    }
}

The _state and state pair is pure ceremony. There is one piece of state, but two names for it, and the only reason is that the public type and the internal type differ. Explicit backing fields let you declare both on a single property:

class MyViewModel : ViewModel() {
    val state: StateFlow<UiState>
        field = MutableStateFlow(UiState())

    fun update() {
        state.value = state.value.copy(loading = true)
    }
}

The property type is StateFlow<UiState>, so callers outside the class see a read only stream. The field declaration gives the backing field the type MutableStateFlow<UiState>, and inside the declaring scope the name state resolves to that narrower type, which is why state.value = compiles. One property, two visibilities of the same object.

How the compiler tells the two types apart

Inside the FIR frontend, a backing field is its own declaration. The FirBackingField node is a variable with its own return type, separate from the property it belongs to:

abstract class FirBackingField : FirVariable(), FirTypeParametersOwner, FirStatement {
    abstract override val returnTypeRef: FirTypeRef
    abstract val propertySymbol: FirPropertySymbol
    abstract override val initializer: FirExpression?
}

Every property has a backing field node, even an ordinary one, so the compiler needs a way to recognize the explicit case. It does that by checking whether the field is the auto generated default or something the author wrote:

val FirProperty.hasExplicitBackingField: Boolean
    get() = backingField != null && backingField !is FirDefaultPropertyBackingField

When hasExplicitBackingField is true, the resolver exposes the backing field's specific type to code inside the property's scope and the property's declared type everywhere else. This is what makes state behave like a MutableStateFlow internally and a StateFlow externally without a cast.

Why the feature is restricted to final val properties

The feature carries deliberate restrictions, enforced by FirExplicitBackingFieldForbiddenChecker. An explicit backing field is allowed only on a val, only when the property is effectively final, and never on an interface, abstract, expect, or extension property. The backing field's visibility must also be more restrictive than the property's.

The reason traces back to how the two types are reconciled. If a property could be overridden, a subclass might supply a different backing field type, and callers could no longer reason about which type they are holding. Requiring the property to be final removes that ambiguity. The visibility rule follows the same logic: the wider, public type is the contract, and the narrower type is an implementation detail that must not leak past the scope that is allowed to see it.

Context parameters become stable

Context parameters also graduate to stable in 2.4, and they replace the earlier context receivers experiment rather than extending it:

ContextParameters(sinceVersion = KOTLIN_2_4, "KT-72222"),

A context parameter is a dependency that a function declares it needs from its surrounding context, without taking it as an ordinary argument. The difference from context receivers is that context parameters are named:

context(logger: Logger)
fun log(message: String) {
    logger.info(message)
}

Any caller that has a Logger in scope can call log("hello") and the compiler passes the logger automatically. The name logger makes the dependency referable inside the body, which the older unnamed context receivers could not do cleanly.

How a context parameter is represented and resolved

In FIR, a context parameter is not a new kind of node. It is an ordinary value parameter tagged with a kind:

enum class FirValueParameterKind {
    Regular,
    ContextParameter,
    LegacyContextReceiver,
}

A declaration's context parameters live in contextParameters: List<FirValueParameter> on FirCallableDeclaration, each one carrying valueParameterKind = ContextParameter. The LegacyContextReceiver entry is kept only so the parser can still read old context receiver syntax while it is being phased out.

Resolution is where the implicit passing happens. When the compiler resolves a call to a function with context parameters, it walks the context parameters and, for each one, searches the implicit values available in the current scope for a value whose type matches. The outcome is decided by how many candidates it finds:

  • Zero matches: the compiler reports NoContextArgument. There is nothing in scope to satisfy the parameter, so the call does not compile.
  • Exactly one match: that value is passed as the context argument and a subtype constraint ties it to the expected type.
  • More than one match: the compiler reports AmbiguousContextArgument, because it cannot choose between two equally valid values.

This is the behavior that makes context parameters feel implicit. You never write the argument, but the resolver is doing a real type directed lookup at every call site, and it fails loudly when the context is missing or ambiguous rather than guessing.

Explicit context arguments are still experimental

2.4 also introduces a way to pass a context argument by name, with -Xexplicit-context-arguments, which lets you disambiguate manually:

context(logger: Logger)
fun log(message: String) { logger.info(message) }

fun caller(primary: Logger, audit: Logger) {
    log(logger = primary)
}

Note the status difference. The flag is introduced in 2.4, but as an experimental opt in. The language feature that turns explicit context arguments on by default, ExplicitContextArguments, has a sinceVersion of 2.5, so in 2.4 you have to ask for it. Context parameters themselves are stable, but naming the argument at the call site is not yet.

Collection literals

Collection literals let you build a collection with bracket syntax, [1, 2, 3], instead of a factory call. Unlike the previous two features, this one is not stable. It is registered with no sinceVersion at all, meaning no language version enables it automatically:

CollectionLiterals(sinceVersion = null, issue = "KT-80489", enabledInLatestLVTests = true),

You opt in with -Xcollection-literals, introduced in 2.4.0 and described as experimental support. Bracket syntax itself is not new to the parser. Kotlin has always accepted [...] inside annotations, for example @Foo([1, 2, 3]). The legacy K1 resolver, CollectionLiteralResolver, made that explicit: it computed the kind of container around the literal and reported UNSUPPORTED for anything that was not an annotation, mapping the allowed cases to arrayOf. What 2.4 adds is the ability to use the syntax as a general expression.

How a literal desugars into an of call

The K2 resolution lives in CollectionLiteralResolution.kt, and the rule it follows is simple to state: a collection literal is a call to an operator named of on the expected type. You can see the call being constructed with that name and operator origin (simplified):

val callInfo = CallInfo(
    collectionLiteral,
    CallKind.CollectionLiteral,
    OperatorNameConventions.OF,
    explicitReceiver = null,
    argumentList = collectionLiteral.argumentList,
    origin = FirFunctionCallOrigin.Operator,
)

The literal [a, b, c] becomes of(a, b, c) resolved against the type the expression is expected to produce. Because the target type drives resolution, a collection literal needs an expected type. The standard library provides of operators for the common collection types, so val xs: List<Int> = [1, 2, 3] works once the feature is enabled, and a custom type opts in by declaring the operator on its companion object:

class IntBox(val values: List<Int>) {
    companion object {
        operator fun of(vararg elements: Int): IntBox = IntBox(elements.toList())
    }
}

val box: IntBox = [1, 2, 3]

Here [1, 2, 3] resolves to IntBox.of(1, 2, 3), and an empty [] resolves to IntBox.of(). Because resolution runs through the normal overload machinery, multiple of overloads are allowed and the most specific one wins, exactly as it would for a hand written call. The compiler's own codegen tests cover this, including companionBlockOf.kt and resolvesToOperator.kt, which check that literals dispatch to the right of overload across member, vararg, and extension forms.

Standard library additions

Beyond the language, 2.4 brings a handful of standard library changes that are easy to miss.

The most broadly useful is the isSorted family, added across iterables, arrays, and sequences. Every variant delegates to one comparator based implementation:

public fun <T> Sequence<T>.isSortedWith(comparator: Comparator<in T>): Boolean {
    val iterator = iterator()
    if (!iterator.hasNext()) return true
    var current = iterator.next()
    while (iterator.hasNext()) {
        val next = iterator.next()
        if (comparator.compare(current, next) > 0) return false
        current = next
    }
    return true
}

The implementation walks adjacent pairs and returns false at the first one that is out of order, so it short circuits instead of scanning the whole sequence. A collection with fewer than two elements is sorted by definition. The convenience overloads are thin wrappers over this: isSorted() calls isSortedWith(naturalOrder()), isSortedDescending() calls it with reverseOrder(), and the isSortedBy variants compare selector values with compareValues, treating null as less than any non null value. All of them carry @SinceKotlin("2.4").

The UUID API also reaches a milestone. The kotlin.uuid.Uuid class graduates to stable, marked @SinceKotlin("2.4") with @WasExperimental(ExperimentalUuidApi::class), so parsing, formatting, and comparing UUIDs no longer requires an opt in. The random generation functions are a half step behind: generateV4 and generateV7 are still annotated @ExperimentalUuidApi. In other words, working with UUID values is stable, but minting new random ones from the standard library remains experimental.

Rounding out the list, unsigned to big integer conversions arrive as UInt.toBigInteger() and ULong.toBigInteger() on the JVM, replacing the old string based workarounds. On the annotation side, 2.4 stabilizes the @all use site target, registered as AnnotationAllUseSiteTarget, which applies an annotation to every relevant element of a property at once, and it adjusts the default target rules through PropertyParamAnnotationDefaultTargetMode so that an unqualified annotation on a constructor property lands where you would expect.

Conclusion

In this article, you've explored what Kotlin 2.4 changes beneath its release notes. You saw that the K2 only baseline is encoded in the LanguageVersion enum, that partial linkage is now permanently on with only its log level left to configure, and that intra module inlining moved into klib compilation. You traced explicit backing fields down to the FirBackingField node and the final val restrictions that keep its two types coherent, watched context parameters resolve through a type directed scope search that reports NoContextArgument or AmbiguousContextArgument, and followed a collection literal as it desugars into an of operator call against its expected type.

Understanding these internals helps you read the features as design decisions rather than syntax. Explicit backing fields exist to retire the _state and state pair, but the final val constraint tells you exactly when you can use them. Context parameters look implicit, yet knowing that resolution is a real type lookup explains why an ambiguous context is a compile error and not a silent choice. Collection literals need an expected type because the whole mechanism is built on resolving of against that type.

Whether you are simplifying a ViewModel's state exposure, threading a logger or a transaction through a call graph with context parameters, or experimenting with collection literals in a DSL, this knowledge gives you a clearer model of what the compiler is doing on your behalf. The surface of a release tells you what changed. The internals tell you why it changed that way, and when each new tool is the right one to reach for.

As always, happy coding!

Jaewoong (skydoves)