Kotlin Pattern Matching: From Beginner to Advanced

Okay, here’s a comprehensive article on Kotlin Pattern Matching, covering its evolution, core concepts, and advanced techniques, with a word count approaching 5000. I’ve focused on clarity, practical examples, and a progression from basic to advanced usage.

Kotlin Pattern Matching: From Beginner to Advanced

Kotlin, a modern statically-typed programming language, is renowned for its conciseness, expressiveness, and safety features. While Kotlin doesn’t have pattern matching in the same, fully-fledged sense as languages like Haskell, Scala, or Rust (especially before Kotlin 1.9), it offers a powerful combination of constructs that achieve similar results. This article will explore these mechanisms, often collectively referred to as “Kotlin’s approach to pattern matching,” starting with fundamental concepts and progressing to advanced techniques. We will use the term “pattern matching” liberally to describe Kotlin’s facilities for achieving the same outcomes, even if the underlying implementation differs from languages with dedicated pattern matching syntax.

1. Introduction: What is Pattern Matching?

Pattern matching is a programming technique where you test a value against a series of patterns. Each pattern describes a specific structure or condition. When a match is found, the corresponding code block associated with that pattern is executed. This is a powerful way to:

  • Decompose data structures: Extract relevant parts of a complex object (like a data class, list, or sealed class hierarchy) into individual variables.
  • Handle different cases elegantly: Instead of nested if-else chains, pattern matching provides a cleaner and more readable way to handle various possibilities.
  • Improve code clarity: Pattern matching often makes the code’s intent more explicit, reducing the cognitive load for developers.
  • Enhance type safety: Kotlin’s type system works in conjunction with pattern matching to ensure that you handle all possible cases, reducing the risk of runtime errors.

2. The Foundation: when Expression and Basic Type Checks

Kotlin’s primary tool for achieving pattern matching-like behavior is the when expression. Unlike a simple switch statement in languages like Java or C, when is much more versatile.

2.1. The when Expression: A Versatile Switch

The when expression evaluates a subject expression (the value you’re testing) against a series of branches. Each branch consists of a condition (the “pattern”) and a corresponding expression or block of code to execute if the condition is met.

“`kotlin
fun describe(obj: Any): String =
when (obj) {
1 -> “One”
“Hello” -> “Greeting”
is Long -> “Long number”
!is String -> “Not a string”
else -> “Unknown”
}

fun main() {
println(describe(1)) // Output: One
println(describe(“Hello”)) // Output: Greeting
println(describe(1000L)) // Output: Long number
println(describe(2.0)) // Output: Not a string
println(describe(“Other”)) // Output: Unknown
}
“`

In this example:

  • obj is the subject expression.
  • 1, "Hello", is Long, !is String, and else are the conditions (patterns).
  • The arrows (->) separate the conditions from the expressions to be executed.
  • else is the default branch, executed if none of the other conditions are met. It’s crucial for exhaustive when expressions (explained later).

2.2. Type Checks with is and Smart Casts

The is operator is Kotlin’s way to check the type of a variable at runtime. The real power comes from Kotlin’s smart casts. If the is check is successful, Kotlin automatically casts the variable to the checked type within the scope of the corresponding branch.

“`kotlin
fun process(value: Any) {
when (value) {
is String -> println(“String length: ${value.length}”) // Smart cast to String
is Int -> println(“Int squared: ${value * value}”) // Smart cast to Int
else -> println(“Unknown type”)
}
}

fun main() {
process(“Kotlin”) // Output: String length: 6
process(5) // Output: Int squared: 25
process(true) // Output: Unknown type
}
“`

Notice how we can directly use value.length (a String property) and value * value (an Int operation) without explicit casting after the is check. This is the magic of smart casts. The !is operator is the negation, checking if a value is not of a specific type.

2.3. Exhaustive when Expressions

When a when expression is used as an expression (i.e., it returns a value), it must be exhaustive. This means it must cover all possible cases. If it’s not exhaustive, the compiler will issue an error.

kotlin
// ERROR: 'when' expression must be exhaustive, add necessary 'else' branch
fun describeNonExhaustive(obj: Any): String =
when (obj) {
is String -> "It's a string"
is Int -> "It's an integer"
}

To fix this, we add an else branch:

kotlin
fun describeExhaustive(obj: Any): String =
when (obj) {
is String -> "It's a string"
is Int -> "It's an integer"
else -> "Something else" // Makes it exhaustive
}

Exhaustiveness is crucial for type safety, especially when working with sealed classes, as we’ll see later.

3. Beyond Basic Types: Working with Data Classes and Destructuring

Data classes are a cornerstone of Kotlin, designed for holding data. when expressions can be combined with destructuring declarations to elegantly extract and work with the components of data classes.

3.1. Data Classes: Concise Data Holders

kotlin
data class Person(val name: String, val age: Int)

Data classes automatically provide equals(), hashCode(), toString(), copy(), and componentN() functions (for destructuring).

3.2. Destructuring Declarations

Destructuring allows you to unpack the properties of a data class (or any class that provides componentN() functions) into individual variables.

“`kotlin
val person = Person(“Alice”, 30)
val (name, age) = person // Destructuring declaration

println(“Name: $name, Age: $age”) // Output: Name: Alice, Age: 30
“`

3.3. Combining when and Destructuring

This is where things get interesting. You can use destructuring directly within the when condition:

“`kotlin
fun describePerson(person: Person): String =
when (person) {
Person(“Bob”, ) -> “It’s Bob!” // Ignore age with _
Person(
, 25) -> “Someone who is 25”
else -> “Someone else”
}

fun main() {
println(describePerson(Person(“Bob”, 42))) // Output: It’s Bob!
println(describePerson(Person(“Charlie”, 25))) // Output: Someone who is 25
println(describePerson(Person(“Alice”, 30))) // Output: Someone else
}
“`
Another way of matching properties of a data class is to explicitly refer to the properties in the when branch.

“`kotlin
fun describePersonExplicit(person: Person): String =
when {
person.name == “Bob” -> “It’s Bob!” // Explicitly check name
person.age == 25 -> “Someone who is 25”
else -> “Someone else”
}

fun main() {
println(describePersonExplicit(Person(“Bob”, 42))) // Output: It’s Bob!
println(describePersonExplicit(Person(“Charlie”, 25))) // Output: Someone who is 25
println(describePersonExplicit(Person(“Alice”, 30))) // Output: Someone else
}
“`

In this revised example:

  • Person("Bob", _): Matches any Person object where the name is “Bob”. The underscore (_) acts as a wildcard, indicating that we don’t care about the age value.
  • Person(_, 25): Matches any Person object where the age is 25, regardless of the name.
  • when { ... }: This form of when does not use a subject. Instead, each branch is a boolean expression. The first branch that evaluates to true is executed.

The second example, using explicit property checks, provides more flexibility if you need to perform more complex comparisons or logic within each branch, rather than simple equality checks. It’s also the only option if you’re not using a data class (or a class that supports destructuring). The destructuring approach, when applicable, is generally considered more concise and idiomatic.

4. Sealed Classes and Exhaustive Pattern Matching

Sealed classes are a powerful feature for creating restricted class hierarchies. They are crucial for achieving truly exhaustive and type-safe pattern matching in Kotlin.

4.1. Sealed Classes: Defining Restricted Hierarchies

A sealed class is a class that can only have a limited set of subclasses, and all of those subclasses must be declared within the same file as the sealed class itself. This restriction is what enables the compiler to know all possible subtypes.

kotlin
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result() // Singleton object
}

Here, Result is a sealed class. It has three possible subtypes: Success, Error, and Loading. Crucially, no other class outside this file can inherit from Result.

4.2. Exhaustive when with Sealed Classes

The real benefit of sealed classes comes when using them with when. Because the compiler knows all possible subtypes, it can enforce exhaustiveness without requiring an else branch.

“`kotlin
fun handleResult(result: Result): String =
when (result) {
is Result.Success -> “Success: ${result.data}”
is Result.Error -> “Error: ${result.message}”
Result.Loading -> “Loading…”
// No ‘else’ needed!
}

fun main() {
println(handleResult(Result.Success(“Data loaded!”))) // Output: Success: Data loaded!
println(handleResult(Result.Error(“Network error”))) // Output: Error: Network error
println(handleResult(Result.Loading)) // Output: Loading…
}
“`

If you were to add a new subclass to Result (within the same file) and forget to update the when expression, the compiler would immediately flag an error. This is incredibly valuable for maintaining code correctness and preventing unexpected runtime behavior. This compile-time check is one of the most significant advantages of using sealed classes for pattern matching.

4.3. Sealed Interfaces (Kotlin 1.5+)

Kotlin 1.5 introduced sealed interfaces, providing more flexibility than sealed classes. Sealed interfaces allow you to define a restricted hierarchy of interfaces, which can then be implemented by classes or objects.

“`kotlin
sealed interface Expr
data class Const(val number: Double) : Expr
data class Sum(val e1: Expr, val e2: Expr) : Expr
object NotANumber : Expr

fun eval(expr: Expr): Double = when (expr) {
is Const -> expr.number
is Sum -> eval(expr.e1) + eval(expr.e2)
NotANumber -> Double.NaN
}

fun main() {
val expression = Sum(Const(5.0), Const(2.0))
println(eval(expression)) // Output: 7.0
}
“`

The principles are the same as with sealed classes: the compiler knows all possible implementations of the sealed interface, enabling exhaustive when expressions.

5. Advanced Techniques and Considerations

Now that we’ve covered the core concepts, let’s explore some more advanced techniques and considerations for Kotlin’s approach to pattern matching.

5.1. when with Ranges and Collections

when can also work with ranges and collections:

“`kotlin
fun describeNumber(n: Int) {
when (n) {
in 1..10 -> println(“Between 1 and 10”)
in 11..20 -> println(“Between 11 and 20”)
!in 1..100 -> println(“Outside 1 to 100”)
else -> println(“Between 21 and 100”)
}
}

fun checkList(list: List) {
when {
1 in list -> println(“List contains 1”)
list.isEmpty() -> println(“List is empty”)
else -> println(“Other case”)
}
}

fun main() {
describeNumber(5) // Output: Between 1 and 10
describeNumber(15) // Output: Between 11 and 20
describeNumber(101) // Output: Outside 1 to 100
describeNumber(50) // Output: Between 21 and 100

checkList(listOf(1, 2, 3)) // Output: List contains 1
checkList(emptyList())    // Output: List is empty

}
“`

  • in 1..10: Checks if n is within the range 1 to 10 (inclusive).
  • !in 1..100: Checks if n is not within the range 1 to 100.
  • 1 in list: Checks if the list contains the element 1.

5.2. Combining Conditions with || and &&

Within a single when branch, you can combine multiple conditions using the logical OR (||) and AND (&&) operators:

kotlin
fun complexCheck(value: Any) {
when (value) {
is String && value.startsWith("A") -> println("String starting with A")
is Int || value is Double -> println("Number (Int or Double)")
else -> println("Other type")
}
}
fun main(){
complexCheck("Apple") //String starting with A
complexCheck(123) // Number (Int or Double)
complexCheck(10.1) // Number (Int or Double)
}

5.3. Capturing when Subject in a Variable

You can capture the subject of the when expression in a variable, which can be useful for avoiding repeated evaluations:

kotlin
fun processString(input: String): String =
when (val upper = input.uppercase()) {
"HELLO" -> "Formal greeting"
"HI" -> "Informal greeting"
else -> "Other: $upper"
}
fun main(){
println(processString("hello")) //Other: HELLO
}

Here, input.uppercase() is only called once, and the result is stored in the upper variable, which is then used in the when branches.

5.4. Nested when Expressions

You can nest when expressions, although this can sometimes reduce readability. Consider refactoring into separate functions if the nesting becomes too deep.

kotlin
fun nestedWhen(x: Int, y: Int): String =
when (x) {
in 1..10 -> when (y) {
in 1..5 -> "x is small, y is very small"
else -> "x is small, y is not very small"
}
else -> "x is not small"
}
fun main() {
println(nestedWhen(5, 3))
println(nestedWhen(5, 10))
println(nestedWhen(15, 3))
}

5.5. Using when as a Statement

So far, we’ve primarily used when as an expression (returning a value). You can also use it as a statement (performing actions without returning a value). In this case, exhaustiveness is not enforced by the compiler.

“`kotlin
fun processValue(value: Any) {
when (value) {
is String -> println(“Processing string: $value”)
is Int -> println(“Processing integer: $value”)
// No ‘else’ needed – it’s a statement, not an expression
}
}

“`

5.6. Guard Clauses and when

when can sometimes replace guard clauses (early returns in a function to handle specific cases). However, traditional guard clauses with if statements are often more readable for simple checks at the beginning of a function. when is usually preferred for more complex branching logic.

“`kotlin
// Using if as guard clauses (often preferred for simple cases)
fun process(input: String?): String {
if (input == null) return “Input is null”
if (input.isEmpty()) return “Input is empty”
return “Processed: ${input.uppercase()}”
}
// Using When (More suited when there will be multiple checks)
fun processWithWhen(input: String?): String =
when {
input == null -> “Input is null”
input.isEmpty() -> “Input is empty”
else -> “Processed: ${input.uppercase()}”
}

“`

5.7. Limitations and Alternatives

While Kotlin’s when expression and related features provide powerful capabilities, it’s important to acknowledge their limitations compared to full-fledged pattern matching in other languages:

  • No Extractor Patterns (Before Kotlin 1.9): Prior to Kotlin 1.9, there was no direct equivalent to extractor patterns found in Scala, for example. Extractor patterns allow you to define custom logic for matching and deconstructing objects.
  • Limited Destructuring Beyond Data Classes: While destructuring works well with Data Classes and classes implementing componentN(), extending this is limited, especially when working with 3rd party libraries.

6. Kotlin 1.9 and Beyond: Preview of Enhanced Pattern Matching

Kotlin 1.9 introduced a preview of significantly enhanced pattern matching capabilities, bringing it closer to languages like Scala and Rust. These features are still under development and subject to change, but they offer a glimpse into the future of pattern matching in Kotlin.

6.1. Stable when Subject Value

The captured subject value is now guaranteed to be stable, meaning it’s evaluated only once and can be safely used within the when branches, even if it involves side effects.

6.2. Sealed when Statements

Kotlin 1.9 introduces the concept of sealed when statements. When used with a sealed class or interface, a when statement (not just an expression) can be made exhaustive, triggering compiler warnings if not all cases are handled. This provides greater flexibility in how you structure your code. You are no longer forced to return something, even though your intent is to just execute code.

kotlin
fun processResultSealedStatement(result: Result) {
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT") //Needed for the preview.
when (result) { // Now allowed without an 'else' branch!
is Result.Success -> println("Data: ${result.data}")
is Result.Error -> println("Error: ${result.message}")
Result.Loading -> println("Still loading...")
}
}

6.3 Improved Inference for Smart Casts

Smart casts have also been improved in Kotlin 1.9. They are smarter!

“`kotlin
fun smartCastExample(x: Any?) {

@Suppress(“NON_EXHAUSTIVE_WHEN_STATEMENT”, “IMPLICIT_CAST_TO_ANY”, “UNNECESSARY_NOT_NULL_ASSERTION”) //Needed for the preview.
when (x!!) { //We are forcing a non-null here, x is now of type Any, not Any?
is String -> println(x.length) // x is smart-cast to String, even though x!! is Any
is Int -> println(x * 2)
}
}
fun main(){
smartCastExample(“abc”)
smartCastExample(1)
}
``
Prior to 1.9, you would often need to do a safe cast (
as?`) and a null check to do this.

7. Conclusion: The Power of Kotlin’s Pattern Matching Approach

Kotlin’s approach to pattern matching, while not using a single, dedicated match keyword, provides a robust and expressive set of tools for handling different data structures and conditions. The combination of the when expression, type checks with is, smart casts, data class destructuring, and sealed class hierarchies allows developers to write clean, concise, and type-safe code. The enhancements introduced in Kotlin 1.9 (and likely future versions) further solidify Kotlin’s position as a language that embraces the principles of pattern matching, making it even more powerful and enjoyable to use. By understanding and applying these techniques, you can write more maintainable, robust, and expressive Kotlin code.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top