A new sealed subtype silently takes the wrong when branch — find and fix it
PaymentResult is a sealed interface. A third case, Pending, was added last week. The build stayed green, yet pending payments are now shown to users as failures.
Constraints: keep PaymentResult sealed, give Pending its own message, and make any case added to the hierarchy in future break the build instead of slipping through unnoticed.
sealed interface PaymentResult {
data class Approved(val txId: String) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data class Pending(val etaMinutes: Int) : PaymentResult
}
fun message(result: PaymentResult): String = when (result) {
is PaymentResult.Approved -> "Paid, receipt " + result.txId
else -> "Payment failed"
}
Find and fix the bug.
The else branch destroys exhaustiveness: while it is there the compiler stops checking the when, so Pending quietly lands in else at runtime. Drop else and handle each subtype, and any future case then fails the build.
- ✗Adding
elseto awhenover a sealed type just to silence the compiler - ✗Believing a new subtype cannot slip through because the code still compiles
- ✗Thinking
elseand a full set of branches are interchangeable
- →Where else in a codebase would this bug hide after a new subtype is added?
- →When is an
elsebranch over a sealed type actually justified?
Why a pending payment became a failure
The else branch is a promise to the compiler that every remaining case is handled the same way. While it is there the when is complete by construction and no exhaustiveness check runs at all. So adding Pending to the hierarchy produced neither an error nor a warning: the new subtype simply fell into else and got the failure message.
That is exactly what throws away the main benefit of sealed: the compiler knows the full set of direct subtypes, and a when without else must cover all of them.
The fix
fun message(result: PaymentResult): String = when (result) {
is PaymentResult.Approved -> "Paid, receipt " + result.txId
is PaymentResult.Declined -> "Payment failed: " + result.reason
is PaymentResult.Pending -> "Pending, about " + result.etaMinutes + " min"
}
There is no else any more, and that is deliberate. When Chargeback is added to the hierarchy tomorrow, this when stops compiling and has to be completed. The bug turns from silent into impossible.
When else is still appropriate
When there are many cases, only one or two need to be told apart, and you consciously accept that new cases behave as "other". For a domain result such as a payment that is nearly always the wrong call.