Meet Kotlin 2.1 - From Non-Local Exits to Multi-Dollar Strings
Last year was significant for Kotlin, as major version 2.0.0 was released in May. While it introduced many exciting features, most notably the stable K2 compiler, it made almost no changes to the language’s design. However, that changed with the release of version 2.1.0 in November last year. Although still in preview mode, this version introduced several language changes that are worth highlighting and exploring. In this article, we will dive into what’s new in Kotlin 2.1, focusing on the introduced language features.
Prerequisites
Some of the language features introduced in Kotlin 2.1(described later in this article) are still in preview mode, which means you should not use them in production code as they might change in future releases or be removed. To leverage these features in your project, you must enable them via compiler options. For example, when using Gradle, add the following to your build file:
kotlin {
compilerOptions {
freeCompilerArgs.add("-X<feature-name>")
}
jvmToolchain(21)
}
The feature name that should follow “-X” (only if the feature requires it) will be provided in the section describing each feature later in this article.
Additionally, to use these language features in IntelliJ IDEA, you must use version 2024.3.0 or later with K2 mode enabled. You can enable K2 mode in Settings → Languages & Frameworks → Kotlin by checking the “Enable K2 mode” checkbox (an IDE restart is required).
Now that you know the basics, let’s explore the new features introduced in the latest version of Kotlin.
Changes to the when expression in Kotlin 2.1
The when expression in Kotlin is a versatile tool for controlling program flow, offering a concise way to handle multiple conditions. While it is quite powerful, it does not provide the full pattern matching capabilities known from languages like Scala (you can read more about it in the article on Kotlin’s missing features and how to work around them and about handling errors in Kotlin). Kotlin 2.1.0 introduced improvements that address some of these limitations. In this section, we’ll explore the changes made to the when expression.
Exhaustiveness checks for when expressions with sealed classes
The latest version of Kotlin introduced a change to how the when expression with subjects are handled for type parameters with sealed upper bounds. Let’s see what exactly has changed. For this purpose, assume we have a sealed class representing the possible outcomes of an HTTP request:
sealed class HttpRequestResult {
data class Success(val code: Int, val body: String) : HttpRequestResult()
data class Error(val code: Int, val errorMessage: String) : HttpRequestResult()
object Timeout : HttpRequestResult()
class ConnectionError() : HttpRequestResult()
}
After receiving the request result, we want to somehow process it. For simplicity, let’s say we want to generate a String representation based on the result type. To do this, we could implement a function with a type parameter bounded by our sealed class - HttpRequestResult. Prior to Kotlin 2.1, such a function might have looked like this:
fun <T: HttpRequestResult> processHttpRequestResult(
response: T,
successMapper: (T) -> String,
): String =
when (response ) {
is HttpRequestResult.Success -> successMapper(response)
is HttpRequestResult.Error -> "Error returned by server ${response.code}: ${response.errorMessage}"
is HttpRequestResult.Timeout -> "Connection Timeout"
is HttpRequestResult.ConnectionError -> "Connection error"
else -> "But, why??" // compilation error if else branch is missing
}
Note the else branch is required even though the previous branches cover all cases of the sealed class. That is the strange behavior of Kotlin prior 2.1.0, which requires the else branch in when expression for type parameters with sealed upper bounds. If else branch is missing the following compilation error occurs:
“'when' expression must be exhaustive. Add an 'else' branch.”
Kotlin 2.1.0 improves this behavior, so you no longer need to include the else branch:
fun <T: HttpRequestResult> processHttpRequestResult(
response: T,
successMapper: (T) -> String,
): String =
when (response ) {
is HttpRequestResult.Success -> successMapper(response)
is HttpRequestResult.Error -> "Error returned by server ${response.code}: ${response.errorMessage}"
is HttpRequestResult.Timeout -> "Connection Timeout"
is HttpRequestResult.ConnectionError -> "Connection error"
}
So, we saved a few unnecessary characters - it’s the little things that count! This language feature is stable, meaning you don’t need to enable preview mode.
Guard conditions in when with a subject (preview feature “when-guards”)
Now, let’s assume we want to implement error handling that depends on the error code - returning different string representations for 4XX errors versus 5XX errors. Before Kotlin 2.1.0, you might have implemented this using nested when or if/else statements:
fun processHttpRequestResult(response: HttpRequestResult): String =
when (response) {
is HttpRequestResult.Success -> "Success with code ${response.code} and body: ${response.body}"
is HttpRequestResult.Error -> {
when (response.code) {
in 400..499 -> "Client's error ${response.code}: ${response.errorMessage}"
500 -> "Server's error ${response.code}: ${response.errorMessage}"
else -> "Other error returned by server ${response.code}: ${response.errorMessage}"
}
}
is HttpRequestResult.Timeout -> "Connection Timeout"
is HttpRequestResult.ConnectionError -> "Connection error"
}
The current version of Kotlin introduces “guard conditions” for when expressions or statements with a subject. This feature allows you to add an additional condition to when branches, controlling the flow in a more explicit way and flattening the code structure compared to the nested approach above:
fun processHttpRequestResultWithGuard(response: HttpRequestResult): String =
when (response) {
is HttpRequestResult.Success -> "Success with code ${response.code} and body: ${response.body}"
is HttpRequestResult.Error if response.code in (400..499) -> "Client's error ${response.code}: ${response.errorMessage}"
is HttpRequestResult.Error if response.code in (500..599) -> "Server's error ${response.code}: ${response.errorMessage}"
is HttpRequestResult.Error -> "Other error returned by server ${response.code}: ${response.errorMessage}"
is HttpRequestResult.Timeout -> "Connection Timeout"
is HttpRequestResult.ConnectionError -> "Connection error"
}
A few characteristics of this feature:
- The code in the branch is executed only if both the primary and guard conditions are met.
- You can add multiple conditions to the same branch.
- You can use all the logical operators like in the regular if/else expression (&&, ||, ! and in).
- Branches with guard conditions can be mixed with regular branches.
Is it a big change? Not exactly, but it’s a step towards the pattern matching known from other languages. Please remember that this feature is still in preview and must be explicitly enabled (as described in the prerequisites section).
Multi-dollar string interpolation (preview feature “multi-dollar-interpolation”)
Kotlin offers a convenient feature for writing multi-line strings with interpolation. However, a problem is when you need to include a literal $ sign, which in Kotlin is used for interpolation, in your string. This issue is common when working with JSON Schema, where the $ prefix denotes keywords with special meanings. In such cases, you previously had to use a workaround like ${'$'}, which can impact code readability:
val jsonSchema_beforeKotlin2_1_0 = """
{
"${'$'}id" : "https://example/schema/json"
"items" : {
"${'$'}ref" : "#/${'$'}defs/exampleReference",
},
"${'$'}defs" : {
"fileRef" : {
"exampleReference" : "string",
}
}
}
""".trimIndent()
Fortunately, Kotlin 2.1.0 introduced a way to define how many dollar signs trigger interpolation. You specify the number of dollar signs before your multi-line string. All occurrences of the dollar sign in the specified amount are treated as interpolation markers, while any fewer are treated as literal characters. With this new approach, our JSON schema can be written as follows:
val jsonSchema_multiDollarStringInterpolation = $$"""
{
"$id" : "https://example/schema/json"
"items" : {
"$ref" : "#/$defs/exampleReference",
},
"$defs" : {
"fileRef" : {
"exampleReference" : "$$fileRefType",
}
}
}
""".trimIndent()
Here, we defined two dollar signs as the interpolation prefix, so workarounds do not need to print special node names like $id. Actual interpolation occurs only with two dollar signs, as in the case of $fileRefType. Note that this feature is still in preview mode and requires enabling the “multi-dollar-interpolation” feature.
Non-local break and continue (preview feature “non-local-break-continue”)
Before Kotlin 2.1.0, non-local return statements allowed you to exit the enclosing function from within a lambda, rather than just exiting the lambda itself. This feature simplifies control flow by enabling an immediate exit from the outer function when a lambda is invoked. However, it requires that the lambda is passed to an inline function. Let’s see how it works.
Assume we want to implement an event handler that validates whether an event should be processed before actually handling it. For simplicity, consider a few well-known events from the e-commerce order processing domain, along with a list of arbitrarily allowed events:
enum class EventType {
ORDER_CREATED,
ORDER_CANCELLED,
ORDER_COMPLETED,
ORDER_PAID,
ORDER_DELIVERED,
}
val allowedEvents = setOf(
EventType.ORDER_CREATED,
EventType.ORDER_CANCELLED,
EventType.ORDER_COMPLETED,
)
The simplified code for processing and validating the events leveraging non-local return statement might look like this:
fun handleEvents(events: Set<Event>) {
for (event in events) {
validateEvent(event) { invalidEvent ->
println("Invalid event $invalidEvent")
return
}
println("Event $event is valid")
// processing the event...
}
}
private inline fun validateEvent(event: Event, onValidationError: (Event) -> Unit) {
if (event.eventType !in allowedEvents) {
onValidationError(event)
}
}
In this example, the handleEvents function pretends to process events. The arbitrary validation logic is extracted into the validateEvent function, which takes an event and a lambda (Event) -> Unit that is triggered when the event is not allowed. For simplicity, the lambda simply prints a message and returns from the enclosing function.
This implementation is far from perfect because it stops processing the events when the first invalid event is encountered, which is not expected behavior. However, this is only to demonstrate the possibility to exit the handleEvents function from within a lambda using a non-local return statement. Note that this functionality was available even before Kotlin 2.1.0.
Now, let’s advance and change the requirements of the event handler to print a summary after all events are processed. In this case, we can’t simply return from the handleEvents function. Instead, we need to exit the for loop and then print the summary. This is achievable using the break statement. Let’s try replacing return with break:
internal fun handleEvents(events: Set<Event>) {
for (event in events) {
validateEvent(event) { invalidEvent ->
println("Invalid event $invalidEvent")
break
}
println("Event $event is valid")
// processing the event...
}
println("Processing finished. Summary:...")
}
If you compile this code with a Kotlin version earlier than 2.1.0, you’ll encounter an error (the exact error depends on the Kotlin version). However, since Kotlin 2.1.0, non-local break statements are in preview and can be used after enabling the feature (as described in the Prerequisites section). With this change, the code compiles and works as expected, stopping the for loop when the first invalid event is encountered and printing the summary.
Those with a keen eye will notice that, despite the code is working, the compiler issues a warning:
This occurs because the break statement is ambiguous in this context and the compiler is unsure whether it should stop outer for loop or an inner loop that might exist within the validateEvent function. Since validateEvent is an inline function, its code is inserted directly into the calling function at compile time. To resolve this ambiguity, we have two options:
- Define a label to clarify which loop should be stopped.
- Define a callsInPlace contract to inform the compiler that onValidationError is not invoked within an inner loop in validateEvent.
Let’s go with the first solution and add a label. The following code compiles without warnings:
internal fun handleEvents(events: Set<Event>) {
forEachEventLoop@ for (event in events) {
validateEvent(event) { invalidEvent ->
println("Invalid event $invalidEvent")
break@forEachEventLoop
}
println("Event $event is valid")
// processing the event...
}
println("Processing finished. Summary:...")
}
However, this logic still isn’t ideal. The processing stops as soon as the first invalid event is encountered, whereas our goal is to skip invalid events and continue processing the rest. You might already suspect that we can achieve this behavior using a non-local continue statement:
fun handleEvents(events: Set<Event>) {
forEachEventsLoop@ for (event in events) {
validateEvent(event) { invalidEvent ->
println("Invalid event $invalidEvent")
continue@forEachEventsLoop
}
println("Event $event is valid")
// processing the event...
}
println("Processing finished. Summary:...")
}
This works similarly to the non-local break (again, requiring preview mode and a label or contract). Still, instead of terminating the loop, it simply continues to the next iteration when an invalid event is encountered.
This feature fixed an inconsistency in Kotlin - previously, non-local return statements were supported, while break and continue were not. This makes Kotlin’s lambdas even more powerful. Note that these non-local break and continue features are still in preview, but they are planned to become stable in a future Kotlin release (2.2.0).
Summary
Kotlin 2.1 delivers a bunch of innovative updates that streamline your code and enhance its readability and expressiveness. This release builds on Kotlin’s reputation as a modern, developer-friendly language and introduces several key features:
- Enhanced when expression improves exhaustiveness checks for sealed classes meaning you can now write more concise conditional logic without needing redundant else branches.
- Guard conditions in the when expression allow you to add additional filtering directly within
when
expressions, flattening your code structure and making it easier to follow. - Multi-dollar string interpolation simplifies working with literal dollar signs in multi-line strings, particularly useful when handling JSON schemas and other data formats that rely on the
$
sign. - Non-local break and continue fixes an inconsistency in Kotlin and improves control flow within lambdas and complex loops.
These language features not only make your Kotlin code more powerful but also open the gate for even more advanced features in future releases. Enjoy exploring what's new in Kotlin 2.1 and experience firsthand how these enhancements can transform your development workflow.
As this article focuses on language features, it doesn’t cover all recently released changes. If you want to learn the full scope of the changes, I suggest you go through this release note.
Reviewed by: Piotr Dudziński, Paweł Antoniuk, and Szymon Winiarz