Kotlin's when Is the New switch — And if, And instanceof
when replaces switch, long if/else ladders, and instanceof-with-cast in one construct: subject and subjectless forms, range and type matching, exhaustiveness on sealed types, inline subject capture, and the new guard conditions.
The first time a Java engineer sees Kotlin’s when, the brain files it under “oh, their switch.” That’s a fair first read, and it’s wrong in interesting ways. when is a single construct that quietly replaces three separate tools from your Java toolkit: switch, the long if / else if / else if chain, and instanceof-with-cast. Once you internalize what it can do, your Kotlin starts looking noticeably different from your Java — usually shorter, often clearer.
This is the fourth chapter, following control flow. Every sample was run against Kotlin 2.4.10, including the guard-condition syntax near the end, which is recent enough that most tutorials predate it.
Statement or expression — your choice
The first thing to know is that when is both a control-flow statement and a value-producing expression. Same syntax, different uses:
// As a statement — result discarded
when (status) {
HttpStatus.OK -> log("success")
HttpStatus.NOT_FOUND -> log("missing")
else -> log("other")
}
// As an expression — returns a value you use
val message = when (status) {
HttpStatus.OK -> "success"
HttpStatus.NOT_FOUND -> "missing"
else -> "other"
}
The expression form is the one that changes how you write code. You can drop a when straight into a function body, an assignment, or an argument — anywhere a value is expected. A lot of the temporary-variable plumbing that accretes in Java simply disappears.
With a subject, or without
You can write when with no subject at all. Each branch then becomes its own boolean condition:
// With a subject
when (temp) {
in 0..15 -> "cold"
in 16..25 -> "warm"
else -> "hot"
}
// Without a subject — branches test unrelated conditions
when {
temp < 0 -> "freezing"
temp < 16 && humidity > 80 -> "cold and damp"
isRaining && windSpeed > 30 -> "stormy"
else -> "fine"
}
The subjectless form is what replaces those long if / else if ladders. There’s no equivalent in Java’s switch, which demands a subject and constants. A loose rule that serves well: in idiomatic Kotlin, any chain of three or more if / else if branches is probably better as a when.
Range and collection matching with in
Branches match against ranges:
when (httpCode) {
in 200..299 -> "success"
in 300..399 -> "redirect"
in 400..499 -> "client error"
in 500..599 -> "server error"
else -> "unknown"
}
Or against any collection, and you can negate with !in:
val vowels = setOf('a', 'e', 'i', 'o', 'u')
when (letter) {
in vowels -> "vowel"
in 'a'..'z' -> "consonant"
else -> "not a letter"
}
In Java the same logic is a tangle of comparison operators or a Set.contains wrapped in an if; here it reads like a specification.
Type matching with smart casts
This is where Kotlin starts to feel like a different language. Match on type, and the compiler casts the value for you inside that branch:
fun describe(value: Any): String = when (value) {
is Int -> "an integer: ${value + 1}"
is String -> "a string of length ${value.length}"
is List<*> -> "a list with ${value.size} elements"
else -> "something else"
}
Inside is Int, value is an Int and you do arithmetic on it directly; inside is String, you call .length. No explicit cast, no (String) value boilerplate. Java gained pattern-matching instanceof in 16 and pattern switch later, so the gap has narrowed. But Kotlin’s syntax stays uniform whether you match on type, value, range, or condition — one shape for all of it. This is the smart-cast machinery, which earns its own chapter next.
Multiple values per branch
Comma-separate to combine cases — the clean replacement for switch fall-through, with no break and no accidental drop-through:
when (day) {
SATURDAY, SUNDAY -> "weekend"
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday"
}
Exhaustiveness with enums and sealed types
When the subject is a closed type — an enum, or a sealed class/interface — and when is an expression, the compiler enforces that every variant is handled:
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failure(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun describe(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Paid (id: ${result.transactionId})"
is PaymentResult.Failure -> "Failed: ${result.reason}"
PaymentResult.Pending -> "Still processing"
}
No else branch, and none needed — the three cases are the whole type. Add a fourth variant to PaymentResult later, and every non-exhaustive when across the codebase becomes a compile error. It points at exactly the code that forgot the new case. That’s a structural refactoring aid switch can’t offer, and once you have it you stop relying on code review to catch missing cases. (Deliberately adding an else to a sealed when throws that away — you’ve told the compiler “handle the rest silently,” so it stops warning you. Omit the else on closed types and let exhaustiveness work for you.)
The sealed-types chapter covers why sealed is what makes this possible; here the point is that when is where the payoff lands.
Capturing the subject inline
If the subject is the result of a call, capture it inline with val, scoped to the when:
val message = when (val user = fetchUser()) {
null -> "no user found"
else -> "hello, ${user.name}"
}
// `user` is not visible outside the when
This avoids two Java habits: re-calling the function in each branch (wasteful), or hoisting a temporary that leaks into the surrounding scope (noisy).
Guard conditions: matching a type and a predicate
Until recently, a branch could match a type or a condition, but not both cleanly. Combining “this type, but only when some property holds” meant nesting an if inside the branch or falling through to a subjectless when. Kotlin now allows a guard — an if clause on a branch — so a single branch can test the type and an extra predicate:
sealed interface Shape {
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape
}
fun classify(s: Shape): String = when (s) {
is Shape.Circle if s.r > 10 -> "big circle"
is Shape.Circle -> "small circle"
is Shape.Rect if s.w == s.h -> "square"
is Shape.Rect -> "rectangle"
}
Two things make this pull its weight. First, the smart cast is already in effect inside the guard — s.r and s.w are reachable because s has been narrowed to the branch’s type before the if runs. Second, exhaustiveness still holds. The guarded branches are refinements, and because each type also has an unguarded catch-all branch, the compiler accepts the when with no else. Guards let you keep the exhaustiveness guarantee while expressing “this case, with a twist” in one line instead of a nested block. It’s a small addition that removes a genuinely awkward shape from older Kotlin.
A realistic block
Everything composed:
fun categorizeRequest(request: HttpRequest): RequestCategory = when {
request.method !in setOf("GET", "POST") -> RequestCategory.UNSUPPORTED
request.path.startsWith("/admin") && !request.isAuthenticated -> RequestCategory.UNAUTHORIZED
request.path == "/health" -> RequestCategory.HEALTH_CHECK
request.contentLength > 10_000_000 -> RequestCategory.LARGE_PAYLOAD
request.headers["X-Internal"] == "true" -> RequestCategory.INTERNAL
else -> RequestCategory.STANDARD
}
Fifteen-to-twenty lines of nested if / else in Java, here as one block: read top to bottom, each branch is one rule, the result type is inferred, no temporary-variable noise.
When not to use when
Its flexibility makes it tempting to overuse. Two anti-patterns:
- Two-way branches. A plain either/or is still better as
if / else.whenearns its keep at three-plus cases or when you want exhaustiveness. - Long blocks of unrelated business logic. A fifty-line
whenwhere each branch does something elaborate is usually a sign to extract functions, or to reach for polymorphism — sealed types carrying their own behaviour. The right time forwhenis when one of its specific strengths is doing real work: exhaustiveness, expression form, smart casts, range or guard matching.
Final thoughts
when is not Kotlin’s switch. It’s a general-purpose conditional that happens to cover what switch does. The mental shift is small but real: stop thinking “the switch keyword” and start thinking “the default tool for any multi-way decision.” Do that, and three Java patterns fade out. The if / else if ladder becomes a subjectless when. instanceof-plus-cast becomes is with a smart cast. And the enum switch with a defensive default becomes an exhaustive when that needs no else. One keyword retiring three tools, and with guards, doing it without giving up the exhaustiveness check.
The middle of those — instanceof-plus-cast becoming is-plus-smart-cast — deserves a closer look on its own. Next: smart casts, the feature that lets a type check double as the cast, and the rules that decide when it kicks in.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments