Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 084 / 100

Kotlin when expressions: real-world lessons and examples

A better switch.

Expona Admin7 min read

Kotlin when expressions: real-world lessons and examples. A better switch.

Introduction

Kotlin's when expressions give us a powerful tool that goes far beyond the simple switch statements we know from Java. In production systems handling payment flows, user authentication, or order state machines, we rely on when to make our intent clear and our code maintainable. Over 84 days of building real applications, we've learned that mastering Kotlin's when expressions—whether matching values, types, ranges, or combining them with sealed classes—separates code that works from code that scales.

Match values, types and ranges

The simplest form of when matches discrete values, much like Java's switch. But Kotlin lets us go further in a single expression: matching exact values, types, and numeric ranges all at once.

Consider a ride-hailing service that needs to determine surge pricing based on demand level:

kotlin
fun getSurgeMultiplier(demandLevel: Int): Double = when (demandLevel) {
    0, 1 -> 1.0
    2, 3 -> 1.5
    4, 5 -> 2.0
    else -> 2.5
}

We can group multiple values with commas. But what if we need ranges? In an e-commerce system calculating shipping costs by weight (in grams):

kotlin
fun getShippingCost(weightGrams: Int, zone: String): Double = when {
    weightGrams in 0..500 && zone == "local" -> 2.99
    weightGrams in 501..2000 && zone == "local" -> 4.99
    weightGrams in 0..500 && zone == "international" -> 12.99
    weightGrams in 501..2000 && zone == "international" -> 19.99
    else -> 0.0
}

Type matching is equally powerful. When handling API responses that might be success, network error, or validation error, we can match types directly:

kotlin
fun handleResponse(result: Any): String = when (result) {
    is String -> "Success: $result"
    is IOException -> "Network error: ${result.message}"
    is IllegalArgumentException -> "Validation failed: ${result.message}"
    else -> "Unknown result type"
}

The is keyword checks both the type and performs smart casting automatically. After the type check passes, we can use the variable as if it's already been cast.

Return a value directly

One of the biggest advantages of when over Java's switch is that it's an expression, not a statement. This means every branch can produce a value that the entire when evaluates to.

In a SaaS product managing subscription tiers, we might calculate feature availability:

kotlin
data class Subscription(val tier: String, val monthlySpend: Double)

fun canAccessAdvancedReporting(subscription: Subscription): Boolean = when {
    subscription.tier == "enterprise" -> true
    subscription.tier == "pro" && subscription.monthlySpend >= 99 -> true
    else -> false
}

No need for a variable, no need for multiple return statements. The entire when is the return value. This pattern keeps functions pure and side-effect-free, which is critical in trading systems or any real-time processing pipeline.

Complex calculations also benefit. Consider determining order fulfillment location in a logistics network:

kotlin
fun selectFulfillmentWarehouse(
    order: Order,
    availableWarehouses: List
): Warehouse? = when {
    availableWarehouses.isEmpty() -> null
    order.priority == "express" -> availableWarehouses.minByOrNull { it.distanceToCustomer }
    order.weight > 50 -> availableWarehouses.find { it.hasHeavyGoodsFacility }
    else -> availableWarehouses.find { it.hasInventory(order.items) }
}

Each branch returns a complete, computed value. The caller receives exactly what they need without intermediate variables cluttering the function body.

Exhaustive with sealed classes and enums

Sealed classes are where when truly shines and protects us from bugs. When you match against all possible subtypes of a sealed class, Kotlin's compiler verifies that you've covered every case. This is invaluable in state machines and domain-driven design.

A CRM system managing contact states might define:

kotlin
sealed class ContactState {
    data class Active(val lastTouched: Long) : ContactState()
    data class Inactive(val inactiveSince: Long) : ContactState()
    data class Archived(val archivedAt: Long, val reason: String) : ContactState()
}

fun getContactDisplayName(contact: Contact, state: ContactState): String = when (state) {
    is ContactState.Active -> "${contact.name} (active)"
    is ContactState.Inactive -> "${contact.name} (inactive)"
    is ContactState.Archived -> "${contact.name} (archived: ${state.reason})"
}

If we add a new subtype to ContactState later, the compiler will immediately flag all when expressions that don't handle it. This catch happens at compile time, not in production logs at 3 AM.

Enums work the same way:

kotlin
enum class PaymentStatus {
    PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED
}

fun getPaymentStatusColor(status: PaymentStatus): String = when (status) {
    PaymentStatus.PENDING -> "#FFA500"
    PaymentStatus.PROCESSING -> "#87CEEB"
    PaymentStatus.COMPLETED -> "#00AA00"
    PaymentStatus.FAILED -> "#FF0000"
    PaymentStatus.REFUNDED -> "#808080"
}

Again, if someone adds a new status without updating this function, the compiler complains. For mission-critical code in fintech or healthcare, this exhaustiveness guarantee is worth its weight in gold.

Guards with if in Kotlin 2.1+

Kotlin 2.1 introduced guard clauses directly within when branches using if. This elegantly combines pattern matching with conditional logic without requiring a separate conditional or a helper function.

In a travel booking system, we might validate and process different booking types:

kotlin
sealed class Booking {
    data class FlightBooking(val flightNumber: String, val passengers: Int) : Booking()
    data class HotelBooking(val checkInDate: String, val nights: Int) : Booking()
    data class CarRentalBooking(val carType: String, val rentalDays: Int) : Booking()
}

fun processBooking(booking: Booking): String = when (booking) {
    is Booking.FlightBooking if booking.passengers > 9 -> "Group booking: ${booking.flightNumber}"
    is Booking.FlightBooking -> "Individual flight: ${booking.flightNumber}"
    is Booking.HotelBooking if booking.nights > 30 -> "Extended stay: ${booking.checkInDate}"
    is Booking.HotelBooking -> "Standard hotel: ${booking.checkInDate}"
    is Booking.CarRentalBooking if booking.carType in listOf("SUV", "Van") -> "Large vehicle rental"
    is Booking.CarRentalBooking -> "Standard rental"
}

The if guard executes only after the type has been matched and smart-cast. This prevents null-pointer exceptions and keeps the logic localized to the branch where it belongs. No separate validation function, no boolean flags—just clean, declarative intent.

Common mistakes

Using else too broadly. In sealed class matching, an else branch can hide missing cases. If you add a new subtype but forget to add a corresponding when branch, the else silently handles it. Instead, rely on exhaustiveness. Only add else for truly unexpected fallbacks.

Mixing statements and expressions. Treating when as a statement (executing side effects) rather than an expression (returning values) leads to verbose, imperative code. When possible, return a value directly from each branch rather than assigning to a variable after the when block.

Forgetting smart casting after type checks. After matching a type with is, you don't need an explicit cast. Forgetting this leads to redundant code. Once the type is matched, the variable is already cast in that branch's scope.

Over-nesting conditions. Complex conditions in when expressions can become hard to read. Use guard clauses (if in Kotlin 2.1+) or extract helper functions for clarity. A branch that's more than one or two lines of logic is a sign you should refactor.

Key takeaways

  • Match values, types, and ranges in a single expression to handle multiple concerns in one place.
  • Use when as an expression to return values directly, keeping functions pure and readable.
  • Leverage sealed classes and enums for exhaustive matching; the compiler enforces coverage and catches bugs at build time.
  • Use guard clauses with if (Kotlin 2.1+) to combine pattern matching with conditional logic without nesting or helper functions.
  • Trust exhaustiveness over else to catch missing cases early in production-critical state machines and domain models.
  • Keep individual branches concise; extract complex logic into helper functions for maintainability.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles