Polyglot
Languages Kotlin pattern matching
Kotlin § pattern-matching

Pattern matching

Kotlin’s when is the principal multi-way dispatch construct — substantially more expressive than Java’s switch. The when admits value matching (literals, ranges, type patterns), condition matching (boolean expressions in each branch), pattern matching with smart casts (binding via is), and exhaustiveness over sealed types and enums. Unlike switch, when is an expression — produces a value. The combination — value and condition matching, exhaustiveness over sealed types, smart casts on is patterns, the expression-form, the substantial range/in matching — is the substance of Kotlin’s pattern-dispatch surface. Kotlin does not have substantial destructuring patterns (à la Rust); the conventional substitutes are destructuring declarations and property access on smart-cast values.

when with subject

The principal form:

when (n) {
    0 -> "zero"
    1, 2, 3 -> "small"                             // multiple values
    in 4..10 -> "medium"                           // range
    !in 11..100 -> "out of range"
    else -> "large"
}

The subject (n) is matched against each branch’s pattern; the first matching branch’s body runs.

The when is exhaustive when used as expression — must cover all possibilities or include else:

val category = when (n) {
    0 -> "zero"
    in 1..10 -> "small"
    else -> "other"
}

when without subject

For arbitrary boolean conditions:

val category = when {
    n < 0 -> "negative"
    n == 0 -> "zero"
    n < 100 -> "small"
    else -> "large"
}

The form admits substantial conditional logic without a single subject — equivalent to if/else if/else chains but conventionally clearer.

is patterns

The is (and !is) admits type matching with smart cast:

fun describe(value: Any?): String = when (value) {
    null -> "nothing"
    is String -> "string of length ${value.length}"   // smart cast to String
    is Int -> "int: ${value.toString(2)}"             // smart cast to Int
    is List<*> -> "list of ${value.size}"             // smart cast to List<*>
    is Map<*, *> -> "map of ${value.size}"
    else -> "other: $value"
}

The smart cast admits using the value as the matched type within the branch — substantial conciseness.

Range patterns

val grade = when (score) {
    in 90..100 -> "A"
    in 80..89 -> "B"
    in 70..79 -> "C"
    in 60..69 -> "D"
    !in 0..100 -> "invalid"
    else -> "F"
}

val ageGroup = when (age) {
    in 0..12 -> "child"
    in 13..19 -> "teen"
    in 20..64 -> "adult"
    else -> "senior"
}

Multiple values per branch

val isWeekend = when (day) {
    DayOfWeek.SATURDAY, DayOfWeek.SUNDAY -> true
    else -> false
}

val description = when (n) {
    0, 1, 2 -> "few"
    3, 4, 5 -> "several"
    in 6..10 -> "many"
    else -> "lots"
}

when over sealed types

The principal Kotlin pattern — exhaustive when over sealed hierarchies:

sealed class Shape {
    data class Circle(val radius: Double) : Shape()
    data class Square(val side: Double) : Shape()
    data class Triangle(val base: Double, val height: Double) : Shape()
}

fun area(shape: Shape): Double = when (shape) {
    is Shape.Circle -> Math.PI * shape.radius * shape.radius
    is Shape.Square -> shape.side * shape.side
    is Shape.Triangle -> shape.base * shape.height / 2
    // No else — compiler verifies exhaustiveness
}

The smart cast (is Shape.Circle admits shape.radius access) admits substantial conciseness.

If a new variant is added, the compiler reports an error in when blocks lacking a branch.

when over enums

enum class Direction { NORTH, SOUTH, EAST, WEST }

fun opposite(d: Direction): Direction = when (d) {
    Direction.NORTH -> Direction.SOUTH
    Direction.SOUTH -> Direction.NORTH
    Direction.EAST -> Direction.WEST
    Direction.WEST -> Direction.EAST
}

// Exhaustive — compiler checks

when with capturing

The subject may be a binding — admits substantial conciseness:

fun process(): Int = when (val result = compute()) {
    is Result.Success -> result.value
    is Result.Failure -> 0
}

The val result = ... admits using the computed value in each branch without recomputation.

Destructuring with when

For data classes, when admits matching on type and then destructuring:

sealed class Action {
    data class Click(val x: Int, val y: Int) : Action()
    data class Type(val key: String) : Action()
    object Submit : Action()
}

fun handle(action: Action): String = when (action) {
    is Action.Click -> {
        val (x, y) = action                        // destructure
        "clicked at ($x, $y)"
    }
    is Action.Type -> "typed: ${action.key}"
    Action.Submit -> "submitted"
}

The destructuring form is conventional; alternatively, the smart cast admits direct property access:

fun handle(action: Action): String = when (action) {
    is Action.Click -> "clicked at (${action.x}, ${action.y})"  // direct access
    is Action.Type -> "typed: ${action.key}"
    Action.Submit -> "submitted"
}

if-else chains as alternative

For substantial dispatch on type, an if-else if chain is admitted but conventionally less clear than when:

fun describe(value: Any?): String {
    if (value == null) return "nothing"
    if (value is String) return "string"
    if (value is Int) return "int"
    return "other"
}

The conventional when form is conventionally clearer:

fun describe(value: Any?): String = when (value) {
    null -> "nothing"
    is String -> "string"
    is Int -> "int"
    else -> "other"
}

Common patterns

State machine dispatch

sealed class State {
    object Idle : State()
    data class Running(val startedAt: Instant) : State()
    data class Done(val result: String) : State()
    data class Failed(val error: Throwable) : State()
}

fun describe(state: State): String = when (state) {
    State.Idle -> "Ready"
    is State.Running -> "Running since ${state.startedAt}"
    is State.Done -> "Done: ${state.result}"
    is State.Failed -> "Failed: ${state.error.message}"
}

HTTP status dispatch

fun describeStatus(code: Int): String = when (code) {
    200 -> "OK"
    201 -> "Created"
    204 -> "No Content"
    in 300..399 -> "Redirect"
    in 400..499 -> "Client Error"
    in 500..599 -> "Server Error"
    else -> "Unknown"
}

Token dispatch

sealed interface Token {
    data class Number(val value: Double) : Token
    data class Identifier(val name: String) : Token
    data class StringLit(val value: String) : Token
    object LParen : Token
    object RParen : Token
}

fun describe(t: Token): String = when (t) {
    is Token.Number -> "number(${t.value})"
    is Token.Identifier -> "ident(${t.name})"
    is Token.StringLit -> "string(${t.value})"
    Token.LParen -> "("
    Token.RParen -> ")"
}

Action / event dispatch

sealed class Event {
    data class Click(val x: Int, val y: Int) : Event()
    data class KeyPress(val key: Char) : Event()
    data class Scroll(val delta: Int) : Event()
}

fun process(event: Event) {
    when (event) {
        is Event.Click -> handleClick(event.x, event.y)
        is Event.KeyPress -> handleKey(event.key)
        is Event.Scroll -> handleScroll(event.delta)
    }
}

Result dispatch

fun process(result: Result<String>): String = when {
    result.isSuccess -> result.getOrThrow()
    result.isFailure -> "Error: ${result.exceptionOrNull()?.message}"
    else -> "Unknown"
}

// Or with kotlin.Result:
fun process(result: Result<String>): String = result.fold(
    onSuccess = { it },
    onFailure = { "Error: ${it.message}" }
)

Type-based dispatch with cast

fun handle(value: Any): String = when (value) {
    is String -> "string of length ${value.length}"
    is Int -> "int: $value (binary: ${value.toString(2)})"
    is Double -> "double: $value"
    is Boolean -> "bool: $value"
    is List<*> -> "list of ${value.size} items"
    is Map<*, *> -> "map of ${value.size} entries"
    null -> "null"
    else -> "unknown: $value (type: ${value.javaClass.simpleName})"
}

Range and enum

enum class HttpStatusFamily {
    INFORMATIONAL, SUCCESS, REDIRECT, CLIENT_ERROR, SERVER_ERROR, UNKNOWN
}

fun classify(code: Int): HttpStatusFamily = when (code) {
    in 100..199 -> HttpStatusFamily.INFORMATIONAL
    in 200..299 -> HttpStatusFamily.SUCCESS
    in 300..399 -> HttpStatusFamily.REDIRECT
    in 400..499 -> HttpStatusFamily.CLIENT_ERROR
    in 500..599 -> HttpStatusFamily.SERVER_ERROR
    else -> HttpStatusFamily.UNKNOWN
}

when with in set

val isVowel = when (char.lowercaseChar()) {
    in setOf('a', 'e', 'i', 'o', 'u') -> true
    else -> false
}

val isHttpMethod = when (method) {
    in setOf("GET", "POST", "PUT", "DELETE", "PATCH") -> true
    else -> false
}

Smart cast with property access

sealed class Response {
    data class Success(val data: List<Item>) : Response()
    data class Error(val code: Int, val message: String) : Response()
}

fun describe(r: Response): String = when (r) {
    is Response.Success -> "Got ${r.data.size} items"        // smart cast: r.data
    is Response.Error -> "Error ${r.code}: ${r.message}"     // smart cast: r.code, r.message
}

when returning value

val description = when {
    user.age < 13 -> "child"
    user.age < 20 -> "teen"
    user.age < 65 -> "adult"
    else -> "senior"
}

val color = when (status) {
    Status.ACTIVE -> "green"
    Status.PENDING -> "yellow"
    Status.BANNED -> "red"
}

when with side effect

when (event) {
    is Click -> handleClick(event)
    is KeyPress -> handleKey(event)
    is Scroll -> handleScroll(event)
}

The when as statement (without using the result).

when with combined conditions

val rating = when {
    score >= 90 && answeredAll -> "A+"
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    else -> "F"
}

when with is and value combined

fun process(value: Any?): String = when {
    value == null -> "null"
    value is String && value.isBlank() -> "blank string"
    value is String -> "string: $value"
    value is Int && value > 0 -> "positive int"
    value is Int -> "non-positive int"
    else -> "other"
}

The when {} form admits substantial conditional logic combined with type checking.

Validate-and-dispatch

fun parse(input: String): Result<Token> = when {
    input.isBlank() -> Result.failure(ParseError("empty"))
    input.matches(Regex("\\d+")) -> Result.success(Token.Number(input.toDouble()))
    input.matches(Regex("[a-z]+")) -> Result.success(Token.Identifier(input))
    else -> Result.failure(ParseError("unrecognised"))
}

when with bound subject

fun process(): String = when (val result = compute()) {
    is Result.Success -> result.value.toString()
    is Result.Failure -> "Error: ${result.error}"
}

The val result = ... admits using the value within branches without recomputation.

Recursive ADT processing

sealed class Expr {
    data class Lit(val value: Int) : Expr()
    data class Add(val left: Expr, val right: Expr) : Expr()
    data class Mul(val left: Expr, val right: Expr) : Expr()
}

fun eval(e: Expr): Int = when (e) {
    is Expr.Lit -> e.value
    is Expr.Add -> eval(e.left) + eval(e.right)
    is Expr.Mul -> eval(e.left) * eval(e.right)
}

Comparison: when vs if/else if/else

For binary boolean dispatch, if is conventional:

val absolute = if (n < 0) -n else n

For multi-way value dispatch, when is conventionally clearer:

val category = when {
    n < 0 -> "negative"
    n == 0 -> "zero"
    else -> "positive"
}

A note on the absence of substantial pattern matching

Kotlin’s when does not admit:

  • Destructuring patternscase (x, y) -> ....
  • Nested patternscase Add(Lit(x), _) -> ....
  • Guards on patternscase Lit(x) if x > 0 -> ... (admitted via combined when {} though).

The conventional substitutes:

  • Smart casts for type narrowing.
  • Property access on smart-cast values.
  • Destructuring declarations (val (a, b) = ...) inside the branch.
  • when {} (no subject) for substantial guards.

The mechanism is conventionally sufficient for most patterns; substantially elaborate matching may benefit from the patterns proposed in upcoming Kotlin versions.

A note on the conventional discipline

The contemporary Kotlin pattern-matching advice:

  • Use when for substantial multi-way dispatch.
  • Use when over sealed types — admit exhaustive checking.
  • Use is patterns with smart casts.
  • Use in patterns for range and collection membership.
  • Use when {} (no subject) for substantial boolean conditions.
  • Use when (val x = ...) for binding and matching.
  • Avoid else in when over sealed types — admit compiler exhaustiveness.
  • Use destructuring declarations (val (a, b) = ...) for substantial decomposition.
  • Trust the smart casts — they admit substantial conciseness inside branches.

The combination — when with subject and without, value/range/type/membership patterns, smart casts via is, exhaustive checking over sealed types and enums, the expression-form — is the substance of Kotlin’s pattern-dispatch surface. The discipline produces concise, type-safe, exhaustive dispatch with substantial protection against unhandled cases.