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. when earns its keep at three-plus cases or when you want exhaustiveness.
  • Long blocks of unrelated business logic. A fifty-line when where 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 for when is 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