In the last post, we escaped the architectural quicksand of the "Junk Drawer" object. We took a messy, bug-prone class
and forged it into a clean, precise sealed class
—our custom toolbox where every state has its perfect place.
That’s a great first step. But the real power, the true paradigm shift, comes when we ask our toolbox a question. And honestly, this next part is the reason I'm so passionate about this feature. It's the moment the Kotlin compiler goes from being a simple translator to your vigilant, round-the-clock pair programmer.
The Silent Bug I Used to Write
Let's talk about a silent, insidious type of bug. The kind that doesn't cause a crash right away. It just makes your app do the wrong thing, quietly, until a user complains.
Before sealed classes, I’d handle different states with a when
statement and a "safety net" else
branch. Say we have an enum for a user's subscription status.
enum class Status { FREE, PREMIUM, PRO }
fun showFeature(status: Status) {
when (status) {
Status.PREMIUM -> showPremiumFeature()
Status.PRO -> showProFeature()
else -> showFreeUpsell() // The "catch-all"
}
}
This seems fine, right? The else
branch handles the FREE
case. But six months later, a new requirement comes in. We add a Status.ENTERPRISE
tier.
A developer adds it to the enum
, but they forget to update this specific showFeature
function. What happens? Nothing. The code compiles. At runtime, ENTERPRISE
silently falls into the else
branch, and our most valuable enterprise customers are shown an upsell for the free version. It's embarrassing, and it's a bug that QA might not even catch.
The else
branch isn't a safety net; it's a bug landfill. It’s where unhandled states go to be forgotten.
The Magic Show: Exhaustiveness and Smart Casting
Now, let's see how our sealed DeliveryResult
from last time completely avoids this trap.
sealed class DeliveryResult {
object Preparing : DeliveryResult()
data class Dispatched(val trackingId: String) : DeliveryResult()
data class Delivered(val receiversName: String) : DeliveryResult()
}
When we use when
on a sealed class instance, something magical happens.
fun getStatusMessage(result: DeliveryResult) {
when (result) {
is DeliveryResult.Preparing -> {
println("Your package is being prepared.")
}
is DeliveryResult.Dispatched -> {
// Magic #1: `result` is now a Dispatched type, no casting needed!
println("Shipped! Tracking: ${result.trackingId}")
}
is DeliveryResult.Delivered -> {
// Magic #1 again: `result` is a Delivered type here!
println("Signed for by ${result.receiversName}.")
}
}
// Magic #2: Where's the `else` branch? We don't need it!
}
This is the core of the magic trick.
- Smart Casting: Inside each branch, the compiler is smart enough to know exactly which subtype you're dealing with. It automatically and safely casts
result
for you, so you can access properties liketrackingId
without any extra work. - Exhaustiveness: The compiler sees your
sealed class
VIP list and verifies that you have handled every single possibility. Because you have, it doesn't require anelse
branch. This isn't just a convenience; it's a powerful declaration that your logic is complete.
The Grand Finale: The Intentional Break
Okay, the code is clean. But here’s the finale. This is the moment that separates good code from truly resilient code.
Our product manager tells us we need a new state: InTransit
. Easy enough. We add one line to our sealed class.
sealed class DeliveryResult {
object Preparing : DeliveryResult()
data class Dispatched(val trackingId: String) : DeliveryResult()
data class InTransit(val location: String) : DeliveryResult() // The new state
data class Delivered(val receiversName: String) : DeliveryResult()
}
The instant you add this line, something incredible happens. Your getStatusMessage
function, which was compiling perfectly just seconds ago, is now broken. Your IDE will scream at you with a red underline.
The error will say: 'when' expression must be exhaustive, add necessary 'is InTransit' branch
.
Let that sink in. The compiler has just prevented a production bug. Instead of your new InTransit
state silently falling into a forgotten else
branch, the compiler has stopped the build and handed you a perfect, compile-time to-do list. It is forcing you, the developer, to make a conscious decision about how this new state should be handled.
This isn't a bug; it's a gift. It's the cheapest, safest, and earliest possible way to catch a logic error.
One More Thing: Using when
as an Expression
Here's a pro-tip to make your code even more robust and concise. You can use when
not just as a statement, but as an expression that returns a value.
fun getStatusMessage(result: DeliveryResult): String {
// The `when` block itself now returns the String
return when (result) {
is DeliveryResult.Preparing -> "Your package is being prepared."
is DeliveryResult.Dispatched -> "Shipped! Tracking: ${result.trackingId}"
is DeliveryResult.Delivered -> "Signed for by ${result.receiversName}."
is DeliveryResult.InTransit -> "On its way! Last seen in ${result.location}."
}
}
When you do this, the exhaustiveness check becomes even more critical. If you forget a branch, the compiler will fail the build because it can't guarantee that the expression will return a value. This makes your code more functional, removes the need for temporary mutable variables, and adds yet another layer to your safety net.
This "magic trick" of exhaustiveness is why sealed classes are a cornerstone of modern Kotlin and Android development. It fundamentally changes the development lifecycle by moving the responsibility of correctness from a fallible human at runtime to an infallible compiler at build time.
What's a time a "silent failure" or a forgotten else
branch has come back to bite you? I'd love to hear your war stories in the comments!
Top comments (0)