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 PaymentResult is an implicitly abstract class. You can’t call PaymentResult() directly. Its constructor is protected by default.
  • Success and Failed are data class. You get equals, hashCode, toString, and copy for free, which is very convenient when writing tests.
  • Pending carries no data, so we use data object. It’s a singleton and its toString() prints Pending instead of something like PaymentResult$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.Success branch, the variable result is automatically cast to Success. So you can access result.amount without an explicit cast.
  • With object, you compare directly using PaymentResult.Pending without is. Writing is PaymentResult.Pending is also correct, but unnecessary.
  • The when here is an expression, meaning it returns a value and serves as the function body. An expression when must 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-guards flag. 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 equals generated by data class to compare all data.
  • isSameAs is used for data object, since Pending is 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 class is more concise and has entries and valueOf built 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 when statements. If the same when repeats 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.

kotlinsealed classwhenpattern matchingtype safety