Contents

Leveling up Kotlin: What’s New in 2.2

Leveling up Kotlin: What’s New in 2.2 webp image

With every new Kotlin release, the changelog tends to mix two types of updates: the quiet, incremental improvements you barely notice and those that actually affect your daily work. Kotlin 2.2 fits exactly into that pattern. If you’ve been following the language evolution closely or read our previous Kotlin 2.1 overview you probably remember features like non-local returns or improved string interpolation arriving in preview mode. With Kotlin 2.2, some of those features finally graduated to stable, alongside a few new additions to the language and compiler. Others, like context parameters, are experimental but worth a look, especially if you’re building libraries, DSLs, or experimenting with more advanced Kotlin features.

This short overview will focus on exactly those changes likely to matter for real-world Kotlin projects - no hype, no theoretical language design debates, just the practical stuff.

For whom is this blog post?

This article is for developers who use Kotlin daily (whether you write backend services, or build libraries), or experiment with Kotlin in their side projects.

If you’re wondering:

  • Which Kotlin 2.2 features are stable and ready for production?
  • What’s new in the language itself, not just in tooling?
  • Which features might affect how you write or structure your Kotlin code?

Then this post should answer those questions without going too deep into compiler internals or language design philosophy. If you’re completely new to Kotlin, you might want to start elsewhere - we’ll skip over basic syntax explanations and focus on what’s new compared to Kotlin 2.1. And if you come from a different background, for example, Java, and you’re curious why Kotlin might be worth a look in the first place, you can check our guide comparing Kotlin and Java.

Prerequisites

Before jumping into the list of features, it’s worth clarifying what’s stable and what still requires an explicit opt-in. Some of the language features introduced in Kotlin 2.2 are finally stable and production-ready. Others, especially experimental ones like context parameters, still require enabling them via compiler options. To experiment with these features, ensure your build is configured correctly.

For example, when using Gradle:

// build.gradle.kts

kotlin {
   compilerOptions {
       freeCompilerArgs.add("-Xcontext-parameters")
   }
}

The exact feature name to use depends on the specific experimental feature you’re trying out and it will be pointed out in the relevant sections. So let’s start with what’s finally stable.

Graduated from experimental: what’s stable in Kotlin 2.2

Some language features introduced in previous Kotlin releases started as experimental, meaning they were available for testing but not guaranteed to stay unchanged. With Kotlin 2.2, a few of them finally reached stable status, which means they’re ready for production use and no longer require special compiler flags.

Here’s a quick overview of what graduated to stable in Kotlin 2.2.

Smarter default methods on the JVM

If you’re building Kotlin projects targeting the JVM, Kotlin 2.2 introduces a subtle but significant change in how default method implementations in interfaces are handled. It can affect both your bytecode and binary compatibility. Starting with this release, Kotlin generates default methods directly inside interfaces, following the modern Java approach, unless you explicitly configure it otherwise.

This replaces the older workaround where Kotlin generated additional DefaultImpls classes to hold default implementations behind the scenes. For most developers, this simplifies the generated bytecode and brings Kotlin’s behavior closer to what Java developers expect. However, it also means you must be aware of potential compatibility issues, especially when combining Kotlin and Java in the same project, or when unrelated supertypes declare conflicting default implementations.

To control this behavior, you can use the new stable compiler option jvmDefault, which replaces the older, now deprecated -Xjvm-default flag, for example:

// build.gradle.kts

kotlin {
   compilerOptions {
       jvmDefault = JvmDefaultMode.ENABLE
   }
}

The available options of JvmDefaultMode are:

  • Enable - this is the default in Kotlin 2.2. Default methods are generated directly in interfaces, but compatibility bridges and DefaultImpls classes are still included to maintain binary compatibility with older Kotlin or Java code.
  • No-compatibility - generates only default methods in interfaces, skipping extra compatibility classes: a good option for new codebases that don’t need to support older Kotlin versions.
  • Disable - disables default methods in interfaces completely, reverting to the behavior from Kotlin versions before 2.2 with DefaultImpls only.

For most projects, the default setting works well and simplifies the generated bytecode. But if you maintain libraries, care about compatibility, or work with mixed Kotlin and Java codebases, it’s worth double-checking which option best fits your needs.

Non-local break/continue is now stable

The ability to use break and continue across non-local scopes, for example, inside lambdas, is now stable. If you’re unfamiliar with what this means or why it’s useful, check the examples in our Kotlin 2.1 deep dive.

Better string templates with multiple dollars

Generating templates or DSLs that require $ symbols used to be cumbersome. Kotlin 2.2 stabilizes the multi-dollar string interpolation feature. Again, the details and examples were covered in this section of our previous blog post.

Smarter conditions in when expressions

Writing is more expressive and readable when expressions are just easier now. Kotlin 2.2 stabilizes guard conditions, allowing you to attach additional conditions directly to specific branches. If you haven’t seen this feature before, we covered the idea and examples in our Kotlin 2.1 overview blog post.

Still cooking: experimental and beta features worth trying

Not everything in Kotlin 2.2 is production-ready yet. Some newly introduced features are marked as experimental, which means you can try them out, but they might change or be removed in future versions. If you want to experiment with these features, you need to enable them explicitly via compiler options.

For example, to use context parameters, add the -Xcontext-parameters argument to your build configuration, as shown in the Prerequisites section. Here’s what’s new in Kotlin 2.2 for those willing to explore beyond the stable boundary.

Context parameters: pass dependencies without clutter

Kotlin 2.2 adds context parameters, a language feature simplifying how we pass contextual information through our code. If you’ve ever had to push the same objects like database connections, user details, or feature flags through multiple layers of functions, you know how quickly this clutters method signatures.

Context parameters let you express such requirements in a cleaner, more structured way without resorting to global state or manually passing dependencies everywhere. Context parameters can be applied to functions and properties to declare that they require specific objects to be available in the surrounding scope. These objects become implicitly accessible within the function or property body, without the need to pass them as explicit parameters.

The syntax for functions looks like this:

interface Dependency {
   fun doSomething()
}

interface OtherDependency {
   fun doSomethingElse()
}

context(dependency: Dependency, otherDependency: OtherDependency)
fun someFunction() {
   // dependency and otherDependency are available implicitly
   dependency.doSomething()
   otherDependency.doSomethingElse()
}

And this is an example of context syntax for properties:

interface Config {
   fun isEnabled(feature: String): Boolean
}

context(config: Config)
val isFeatureEnabled: Boolean
   get() = config.isEnabled("someFeature")

This feature helps keep your code clean in situations where passing dependencies explicitly would make function signatures unnecessarily verbose. It’s a practical solution when you want to make things like configuration, feature flags, or technical services available inside a function or computed property, but without spreading them through every layer of your code.

Context parameters let you declare such requirements explicitly, exactly where they matter and nowhere else. Currently, context parameters are supported only for functions and properties. The feature is still experimental, so its capabilities may evolve in future Kotlin releases.

Let’s look at a more realistic example from a multi-layered system:

interface UserContext {
   fun currentUser(): String
   fun hasRole(role: String): Boolean
}

interface FeatureFlags {
   fun isEnabled(feature: String): Boolean
}

interface TransactionManager {
   fun <T> inTransaction(fn: () -> T): T
}

class AccountFacade(
   private val transactionManager: TransactionManager,
   private val accountDeletionService: AccountDeletionService
) {
   context(_: UserContext, _: FeatureFlags)
   fun deleteAccount(accountId: String) {
       transactionManager.inTransaction {
           accountDeletionService.deleteAccount(accountId)
       }
   }
}

class AccountDeletionService {

   context(featureFlags: FeatureFlags)
   private val isDeleteAccountEnabled: Boolean
       get() = featureFlags.isEnabled("deleteAccount")

   context(userContext: UserContext, featureFlags: FeatureFlags)
   fun deleteAccount(accountId: String) {
       if (isDeleteAccountEnabled) {
           ensureAdmin()
           // perform account deletion logic
           println("Account $accountId deleted by user ${userContext.currentUser()}.")
       } else {
           throw FeatureDisabledException("Account deletion is not enabled.")
       }
   }

   context(userContext: UserContext)
   private fun ensureAdmin() {
       require(userContext.hasRole("ADMIN")) {
           "User ${userContext.currentUser()} does not have ADMIN role."
       }
   }
}

In this example:

  • The UserContext and FeatureFlags dependencies are made available exactly where they’re needed.
  • It avoids manually passing these dependencies through every function call.
  • Context parameters indicate the requirements for calling each function, without relying on global state or hidden magic.
  • You may also notice the use of underscore (_) for unnamed context parameters that are required for resolution but aren’t accessed directly. It allows the compiler to propagate them without introducing unused names into the scope. It’s beneficial to pass dependencies down to further calls without using them yourself. You can see this in action in the AccountFacade.deleteAccount(accountId: String) method, where both UserContext and FeatureFlags are declared unnamed context parameters using _.

This pattern makes it easier to build layered systems with explicit boundaries and clean function signatures especially when working with technical concerns like security, configuration or feature toggles. Here’s how calling such a function looks in practice. You provide the required context objects once, and all functions that depend on them can use them implicitly.

Here’s an example of how this pattern simplifies things in practice:

val userContext = object : UserContext {
   override fun currentUser() = "john.doe"
   override fun hasRole(role: String) = role == "ADMIN"
}

val featureFlags = object : FeatureFlags {
   override fun isEnabled(feature: String) = feature == "deleteAccount"
}

val transactionManager = object : TransactionManager {
   override fun <T> inTransaction(fn: () -> T): T = fn()
}

val accountFacade = AccountFacade(
   transactionManager = transactionManager,
   accountDeletionService = AccountDeletionService()
)

context(userContext, featureFlags) {
   accountFacade.deleteAccount("12345")
}

This pattern lets you set up the context once and call functions without constantly passing the same objects around. It keeps your code readable and avoids boilerplate, especially in systems with common contextual dependencies.

Smarter type-based resolution for cleaner code

Kotlin 2.2 introduces context-sensitive resolution, an experimental feature that simplifies working with enums, sealed classes, and object declarations in situations where the expected type is already known to the compiler. Previously, even if it was obvious from the context what type you were working with, you still had to write the full name of enum constants or sealed class members. With context-sensitive resolution, the compiler uses type information from the surrounding code to resolve these names for you, making the code shorter and less repetitive. If you want to try yourself, you must add the -Xcontext-sensitive-resolution argument to compiler configuration.

Here’s a quick comparison of how working with enums looked before, and how it looks with context-sensitive resolution enabled. This is how it currently looks without context-sensitive resolution:

enum class BinaryDigit {
   ONE,
   ZERO,
}

fun toInt(binaryDigit: BinaryDigit): Int = when (binaryDigit) {
   BinaryDigit.ONE -> 1
   BinaryDigit.ZERO -> 0
}

fun fromInt(value: Int): BinaryDigit = when (value) {
   1 -> BinaryDigit.ONE
   0 -> BinaryDigit.ZERO
   else -> throw IllegalArgumentException("Invalid binary digit value: $value")
}

And here’s how the same when expression looks with context-sensitive resolution enabled:

fun toInt(binaryDigit: BinaryDigit): Int = when (binaryDigit) {
   ONE -> 1
   ZERO -> 0
}

fun fromInt(value: Int): BinaryDigit = when (value) {
   1 -> ONE
   0 -> ZERO
   else -> throw IllegalArgumentException("Invalid binary digit value: $value")
}

At first glance, you may notice that thanks to context-sensitive resolution, the explicit qualifier BinaryDigit is no longer needed when working with enum constants inside the when expression. It makes the code significantly shorter, more readable, and less repetitive. But this feature goes beyond simplifying enum usage. By leveraging the surrounding type context, the Kotlin compiler now improves name resolution in many common scenarios. Specifically, it considers expected types from arguments, return types, declarations, and similar constructs to resolve names automatically.

As a result, the following language features benefit:

  • conditions on when expressions with a known subject type
  • type checks using is
  • return statements, both implicit and explicit
  • equality checks like == and !=
  • assignments and initializations
  • function calls with expected argument types.

As a short summary of this feature, context-sensitive resolution enables the compiler to automatically resolve names of enum entries, sealed class members, and object declarations based on the expected type from the surrounding context. It eliminates the need for repetitive fully-qualified names and improves code clarity, while preserving type safety.

No more repeated annotations with @all

Annotating properties in Kotlin can sometimes feel more complicated than it looks. That’s because a single val or var declaration often translates to several elements behind the scenes: the field, the getter, the setter, or even the constructor parameter if declared in a primary constructor. If you wanted your custom annotation to apply to all of those elements, you previously had to list every possible target manually, for example:

@Target(
   AnnotationTarget.FIELD,
   AnnotationTarget.PROPERTY,
   AnnotationTarget.PROPERTY_GETTER,
   AnnotationTarget.PROPERTY_SETTER,
   AnnotationTarget.VALUE_PARAMETER
)
annotation class HexColor

With Kotlin 2.2, you can simplify this using the new experimental @all meta-target. It lets you annotate a property once and have the annotation automatically applied to all relevant parts of that property. So what does it change in practice? Let’s have a look at a quick comparison of how annotating properties looked before, and how it simplifies with the @all meta-target

Before Kotlin 2.2 - manual, repetitive annotation for each target:

data class Square(
   @field:HexColor
   @property:HexColor
   @get:HexColor
   @param:HexColor
   val backgroundColor: String
) {
   @field:HexColor
   @property:HexColor
   @get:HexColor
   @set:HexColor
   var borderColor: String = backgroundColor
}

With Kotlin 2.2 and leveraging @all meta-target the code above can be simplified to:

data class Square(
   // applies @HexColor to field, property, get, param and set - depending if it's var
   @all:HexColor
   val backgroundColor: String
) {
   // applies @HexColor to field, property, get and set
   // param is not applicable here since it's not a constructor parameter
   @all:HexColor
   var borderColor: String = backgroundColor
}

Using @all ensures that the annotation will consistently be applied to all relevant parts of the property, without manually repeating yourself across targets like fields, getters, or constructor parameters. It is especially useful for custom annotations related to validation, serialization, or code generation.

In our example, @HexColor could later be processed by your validation logic or a compile-time tool to verify that color values follow the expected HEX format.

Since @all is experimental in Kotlin 2.2, you must explicitly enable it in your project. You can do this by adding the following compiler option -Xannotation-target-all in the same way as it was described above in the Prerequisties section above.

Keep your helper types close with nested type aliases

If you’ve ever written a Kotlin DSL, there’s a good chance you’ve used typealias to clean up some gnarly generic signatures. And if you’ve done that in a reusable builder class, you’ve probably wished those aliases could live inside the class instead of polluting the top-level namespace. Kotlin 2.2 finally makes that possible.

With the new support for nested type aliases, you can now define type aliases inside classes, objects, or interfaces, as long as the alias doesn’t implicitly capture a type parameter from the outer declaration. That restriction aside, this feature gives you much more flexibility when structuring internal APIs and DSLs. Let’s take a look at a practical example. Say you’re building a lightweight validation DSL:

typealias Predicate<T> = (T) -> Boolean
typealias Rule<T> = Pair<String, Predicate<T>>
typealias Error = String
typealias Validator<T> = (T) -> List<Error>

class ValidatorBuilder<T> {
   private val rules = mutableListOf<Rule<T>>()

   fun rule(error: Error, predicate: Predicate<T>) {
       rules += error to predicate
   }

   fun build(): Validator<T> = { value ->
       rules.filterNot { (_, predicate) -> predicate(value) }
           .map { (msg, _) -> msg }
   }
}

It works fine, but all the type aliases live at the top level. That makes the package more crowded and exposes implementation details that are only relevant inside the builder. Kotlin 2.2 lets you clean this up:

class ValidatorBuilder<T> {
   typealias Predicate<T> = (T) -> Boolean
   typealias Rule<T> = Pair<String, Predicate<T>>
   typealias Error = String
   typealias Validator<T> = (T) -> List<Error>

   private val rules = mutableListOf<Rule<T>>()

   fun rule(error: Error, predicate: Predicate<T>) {
       rules += error to predicate
   }

   fun build(): Validator<T> = { value ->
       rules.filterNot { (_, predicate) -> predicate(value) }
           .map { (msg, _) -> msg }
   }
}

Now everything is scoped to where it’s actually used. No unnecessary leakage of internal naming. Autocompletion becomes more relevant, the file becomes easier to navigate, and you gain more consistency in your code. Remember that nested type aliases can’t implicitly refer to type parameters from the outer scope.

If your alias depends on a generic, you have to declare it explicitly:

class ValidatorBuilder<T> {
   // Not allowed – captures T implicitly
   // typealias Predicate = (T) -> Boolean

   // Allowed – declares T directly
   typealias Predicate<T> = (T) -> Boolean
}

There are a few rules to keep in mind:

  • You still have to follow all the regular typealias rules.
  • Don’t expect more visibility than the types you’re aliasing.
  • Their scope works like nested classes, so they can shadow outer aliases
  • You can mark them internal or private if needed.
  • They don’t work in expect/actual declarations in Kotlin Multiplatform.

It’s a small change that makes your code feel more intentional. Less noise, more structure. That’s a trade-off worth making. Since the feature is still in beta, you’ll need to enable it manually by adding -Xnested-type-aliases argument to the compiler argument as it was described before in Prerequisties section.

Final thoughts on Kotlin 2.2

Kotlin 2.2 might not be a headline-grabber, but it rewards attention. Some long-awaited features have finally landed in stable, like non-local control flow and guard conditions in when. Others, like context parameters, smarter name resolution, or the new @all meta-target, point toward an increasingly expressive and ergonomic language.

Even the more niche changes, such as nested type aliases or JVM method generation tweaks, reflect how the Kotlin team continues to listen to real-world developer needs and challenge old defaults when it makes sense.

Whether writing production code, designing DSLs, or just keeping your tooling sharp, Kotlin 2.2 brings meaningful improvements worth exploring. And if you’re entirely new to Kotlin, now is a pretty good time to dive in.

Reviewed by: Rafał Maciak, Szymon Winiarz

Blog Comments powered by Disqus.