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 patterns —
case (x, y) -> .... - Nested patterns —
case Add(Lit(x), _) -> .... - Guards on patterns —
case Lit(x) if x > 0 -> ...(admitted via combinedwhen {}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
whenfor substantial multi-way dispatch. - Use
whenover sealed types — admit exhaustive checking. - Use
ispatterns with smart casts. - Use
inpatterns for range and collection membership. - Use
when {}(no subject) for substantial boolean conditions. - Use
when (val x = ...)for binding and matching. - Avoid
elseinwhenover 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.