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 — and 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 pointing 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 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 reach for polymorphism — sealed types carrying their own behaviour — or to extract functions. The right time for when is when one of its specific strengths (exhaustiveness, expression form, smart casts, range or guard matching) is doing real work.

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