Sealed Classes and when in Kotlin
1. Overview
When a value can only fall into a few fixed cases, such as a payment result being successful, failed, or pending, you want the compiler to know that list clearly. Kotlin provides sealed classes and sealed interfaces to lock down the inheritance hierarchy. The when expression can check whether you’ve handled all branches.
After reading this article, you’ll know how to declare a sealed hierarchy and write an exhaustive when. You’ll also learn to use guard conditions, write tests for this logic, and recognize when not to use sealed classes.
2. The Problem with Open Inheritance Hierarchies
Try modeling a payment result with an ordinary abstract class:
abstract class PaymentResult
class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()
fun describe(result: PaymentResult): String = when (result) {
is Success -> "OK: ${result.transactionId}"
is Failed -> "Error: ${result.reason}"
else -> "Unknown"
}
The else branch is required. The reason is that anyone, in any module, can create an additional subclass of PaymentResult. The compiler can’t know whether the list of subclasses is complete.
As a result, when someone adds class Refunded : PaymentResult(), the code still compiles normally. The Refunded value silently falls into the "Unknown" branch. This type of error typically only appears in production.
enum class solves part of the problem because the list of constants is fixed. However, each enum constant is a single instance. You can’t attach different transactionId values to each successful payment.
3. Declaring Sealed Classes
Sealed classes combine the advantages of both approaches. The list of subclasses is fixed like an enum, and each subclass still carries its own data like a regular class.
3.1. Dependencies
The examples in this article use Kotlin 2.2 on JVM 21. The test section uses JUnit 5 and AssertJ:
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.20</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
You also need to configure kotlin-maven-plugin with the same version to compile Kotlin source code.
3.2. A Simple Sealed Hierarchy
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
}
Each part of the code above has the following meaning:
sealed class PaymentResultis an implicitly abstract class. You can’t callPaymentResult()directly. Its constructor isprotectedby default.SuccessandFailedaredata class. You getequals,hashCode,toString, andcopyfor free, which is very convenient when writing tests.Pendingcarries no data, so we usedata object. It’s a singleton and itstoString()printsPendinginstead of something likePaymentResult$Pending@1b6d3586.
The most important rule: all direct subclasses must be in the same package and module as the sealed class. You can split them across multiple files in the same package. However, another module using your library won’t be able to add new subclasses. It’s because of this rule that the compiler knows the list of subclasses for certain.
4. Exhaustive When Expressions
4.1. Dropping the else Branch
When the subject of a when expression has a sealed type, you no longer need the else branch:
fun describe(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Paid ${result.amount} VND (transaction ${result.transactionId})"
is PaymentResult.Failed -> "Failed: ${result.reason}"
PaymentResult.Pending -> "Processing..."
}
fun main() {
println(describe(PaymentResult.Success("TX-1001", 250_000)))
println(describe(PaymentResult.Failed("Card expired", retryable = false)))
println(describe(PaymentResult.Pending))
}
The output when running:
Paid 250000 VND (transaction TX-1001)
Failed: Card expired
Processing...
There are three noteworthy points in the code above:
- Smart cast: in the
is PaymentResult.Successbranch, the variableresultis automatically cast toSuccess. So you can accessresult.amountwithout an explicit cast. - With
object, you compare directly usingPaymentResult.Pendingwithoutis. Writingis PaymentResult.Pendingis also correct, but unnecessary. - The
whenhere is an expression, meaning it returns a value and serves as the function body. An expressionwhenmust be exhaustive.
4.2. Compiler Catches Errors When Adding New Cases
Suppose you add a refund status:
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
data class Refunded(val transactionId: String) : PaymentResult()
}
The describe function immediately fails to compile:
e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.
This is the core value of sealed classes. Every place in your code using when on PaymentResult is flagged by the compiler. You can’t forget any place.
4.3. When as a Statement
Since Kotlin 1.7, even when when is used as a statement (without taking a return value), the compiler still reports an error if you’re missing a branch. The condition is that the subject has a sealed, enum, or Boolean type:
fun log(result: PaymentResult) {
when (result) {
is PaymentResult.Success -> println("INFO payment ${result.transactionId}")
is PaymentResult.Failed -> println("WARN ${result.reason}")
PaymentResult.Pending -> println("DEBUG waiting")
is PaymentResult.Refunded -> println("INFO refund ${result.transactionId}")
}
}
If you omit the Refunded branch, you get a compile error, not just a warning like in older Kotlin versions.
5. Sealed Interfaces
A class can inherit from only one parent class, but can implement multiple interfaces. That’s why sealed interface is more flexible when a type belongs to multiple groups at once:
sealed interface OrderEvent
sealed interface Notifiable {
val customerEmail: String
}
data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent
fun handle(event: OrderEvent): String = when (event) {
is OrderPlaced -> "Create invoice for ${event.orderId}"
is OrderShipped -> "Tracking code ${event.trackingCode}"
is StockReserved -> "Reserve item ${event.sku}"
}
fun emailOf(target: Notifiable): String = when (target) {
is OrderPlaced -> "Send order confirmation to ${target.customerEmail}"
is OrderShipped -> "Send delivery notification to ${target.customerEmail}"
}
OrderPlaced is both an OrderEvent and Notifiable. The emailOf function needs only two branches, because the compiler knows that exactly two classes implement Notifiable.
Sealed interfaces also work well with enums. An enum class can implement a sealed interface, and then each enum constant is counted as a branch in when.
5.1. Multiple Levels of Nesting
A subclass can also be sealed. This lets you group cases more granularly:
sealed interface PaymentError {
sealed interface Retryable : PaymentError
data object Timeout : Retryable
data object GatewayBusy : Retryable
data class CardDeclined(val code: String) : PaymentError
}
fun shouldRetry(error: PaymentError): Boolean = when (error) {
is PaymentError.Retryable -> true
is PaymentError.CardDeclined -> false
}
The is PaymentError.Retryable branch covers both Timeout and GatewayBusy, so when is still exhaustive even with just two branches.
6. Guard Conditions in When
Sometimes with the same type, you want to handle it differently depending on the data inside. Kotlin 2.2 officially supports guard conditions (the if keyword after the branch condition):
fun nextAction(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Send invoice"
is PaymentResult.Failed if result.retryable -> "Retry after 30 seconds"
is PaymentResult.Failed -> "Report error to customer"
PaymentResult.Pending -> "Wait for webhook from payment gateway"
is PaymentResult.Refunded -> "Update accounting ledger"
}
A branch with a guard matches only when both the type and the if condition are true. The compiler doesn’t consider a guarded branch to have covered Failed. So if you’re missing a non-guarded is PaymentResult.Failed branch, you still get an exhaustiveness error.
Note: In Kotlin 2.1, guard conditions are a preview feature requiring the
-Xwhen-guardsflag. Check your project’s Kotlin version before using them.
7. Combining in a Service
In real-world code, sealed classes are often the return type of services instead of throwing exceptions:
data class Order(val id: String, val amount: Long)
class PaymentService {
fun charge(order: Order): PaymentResult = when {
order.amount <= 0 -> PaymentResult.Failed("Invalid amount", retryable = false)
order.amount > 50_000_000 -> PaymentResult.Pending
else -> PaymentResult.Success("TX-${order.id}", order.amount)
}
}
fun main() {
val service = PaymentService()
when (val result = service.charge(Order("A42", 120_000))) {
is PaymentResult.Success -> println("Transaction ID: ${result.transactionId}")
else -> println(describe(result))
}
}
Transaction ID: TX-A42
In the charge function, when has no subject, so an else is needed. Here the else is perfectly reasonable because the conditions are arbitrary boolean expressions.
In main, the when (val result = ...) syntax both calls the service and scopes the result variable to the when block. The else branch here is intentional: you only care about the success case. Section 9 will explain the cost of this choice.
8. Writing Tests
Thanks to data class, you can compare results with isEqualTo very concisely:
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class PaymentServiceTest {
private val service = PaymentService()
@Test
fun `valid order returns Success`() {
val result = service.charge(Order("A1", 99_000))
assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
}
@Test
fun `negative amount returns non-retryable Failed`() {
val result = service.charge(Order("A2", -5))
assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
assertThat((result as PaymentResult.Failed).retryable).isFalse()
}
@Test
fun `large order transitions to Pending`() {
assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
}
@Test
fun `retryable error leads to retry action`() {
val action = nextAction(PaymentResult.Failed("Gateway timeout", retryable = true))
assertThat(action).isEqualTo("Retry after 30 seconds")
}
}
This test suite checks three points:
- The first test relies on
equalsgenerated bydata classto compare all data. isSameAsis used fordata object, sincePendingis a singleton, reference comparison is sufficient.- The final test checks the guarded branch.
9. Common Mistakes and When Not to Use Sealed Classes
Adding else to a when on a sealed type. This is the most common mistake. An else branch “just to be safe” will completely disable the exhaustiveness checking mechanism. When you add a new subclass, the compiler stays silent. Only use else when you truly want to group all remaining cases, like in the example in section 7.
Using when without a subject. when { result is Success -> ... } is not checked for exhaustiveness against the sealed hierarchy. Write when (result) so the compiler can analyze it.
Forgetting data for objects. A bare object Pending will usually still run. However, logs will print the class name with a hash code, making debugging harder.
Putting subclasses outside the module. If you’re writing a library and want users to extend it themselves (e.g., via plugins), sealed classes are the wrong choice. Use a regular interface or abstract class instead.
Note: Don’t use sealed classes when:
- The cases are just constants with no data, such as
LOW,MEDIUM,HIGH. In this case,enum classis more concise and hasentriesandvalueOfbuilt in.- The list of subclasses changes very frequently and is contributed to by many teams across multiple modules.
- Behavior should live in the objects themselves (polymorphism via method override) rather than scattered
whenstatements. If the samewhenrepeats in ten places, consider moving the logic into methods on each subclass.
10. Conclusion
Sealed classes and sealed interfaces help you lock an inheritance hierarchy into a single module. This way, the compiler knows all possible cases. Combined with a when that has a subject, you get smart casts, no else needed, and compile errors when you forget to handle a new state.
Use data class for cases that carry data and data object for cases that don’t. Choose sealed interface when a type needs to belong to multiple groups. Kotlin 2.2’s guard conditions help you split branches based on internal data. Most importantly, avoid unnecessary else branches, as they take away the very benefit you’re seeking.