Skip to Content
Course content

21: Sealed Classes and Sealed Interfaces

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was working on a payment processing screen the other day and ran into a problem that I think perfectly illustrates why sealed classes exist. I needed a way to represent the different states of the payment flow: waiting for input, processing the transaction, a successful finish, or a failure.

The Enum Wall

My first instinct was to reach for an enum. It's the standard way to handle a fixed set of options, right? I wrote something like this:

enum class PaymentState {
    IDLE,
    PROCESSING,
    SUCCESS,
    ERROR
}

That worked fine until I realized that a "Success" state isn't just a label—it needs to carry the transaction ID. And an "Error" state needs to carry an error message or a code. Enums are great, but they are singleton instances. I can't have one ERROR instance that simultaneously holds "Insufficient Funds" for one user and "Timeout" for another. I hit a wall immediately.

The "Else" Headache

So, I pivoted. I thought, "I'll just use a standard class hierarchy." I created a base class and subclassed it for each state. This allowed me to add the data I needed:

open class PaymentState
class Idle : PaymentState()
class Processing : PaymentState()
class Success(val transactionId: String) : PaymentState()
class Error(val message: String) : PaymentState()

Now I have my data. But here is where the friction started. I used a when expression to update the UI based on the state, and the compiler started complaining:

fun updateUI(state: PaymentState) {
    when (state) {
        is Idle -> showInput()
        is Processing -> showLoadingSpinner()
        is Success -> showReceipt(state.transactionId)
        is Error -> showError(state.message)
        // The compiler screams: 'when' expression must be exhaustive!
    }
}

Since PaymentState is an open class, the compiler has no way of knowing if some other developer, in some other file, decided to create a RefundedState that inherits from PaymentState. To make the compiler happy, I had to add an else -> {} branch. I hated this. Why? Because if I add a new state to the hierarchy six months from now, the compiler won't warn me that I forgot to handle it in updateUI; it'll just quietly fall into the else block and do nothing. That's a bug waiting to happen.

Closing the Loop with Sealed Classes

This is exactly where sealed comes in. By marking the class as sealed, I'm telling Kotlin: "This class can be subclassed, but only within this same package (and usually this same file)." I'm effectively restricting the hierarchy.

sealed class PaymentState {
    object Idle : PaymentState()
    object Processing : PaymentState()
    data class Success(val transactionId: String) : PaymentState()
    data class Error(val message: String) : PaymentState()
}

I switched Idle and Processing to object because they don't hold any unique data—they're just markers. Now, look what happens to my when expression:

fun updateUI(state: PaymentState) {
    when (state) {
        is PaymentState.Idle -> showInput()
        is PaymentState.Processing -> showLoadingSpinner()
        is PaymentState.Success -> showReceipt(state.transactionId)
        is PaymentState.Error -> showError(state.message)
        // No 'else' required!
    }
}

The compiler now knows every possible subtype of PaymentState. If I add class Refunded : PaymentState() tomorrow, the updateUI function will immediately turn red, forcing me to handle the new case. It transforms a potential runtime crash into a compile-time requirement. It's a massive safety win.

When to ditch the class for an interface

You'll also see sealed interface in the wild. You might wonder why you'd use one over a sealed class. For me, it usually comes down to two things: memory and flexibility.

A sealed class can have a constructor and hold internal state. A sealed interface cannot. If your states are purely behavioral and don't need to share any common private properties, an interface is "lighter." More importantly, a class can only inherit from one other class. If your state objects need to implement multiple different hierarchies, a sealed interface is your only option.

sealed interface CanBeCancelled
sealed interface PaymentState

class Processing : PaymentState, CanBeCancelled
class Success(val id: String) : PaymentState
// Success cannot be cancelled, but Processing can.

I use sealed classes when I have a clear "is-a" relationship with shared data, and sealed interfaces when I'm defining a closed set of types that might fit into multiple categories.




📋 Practical Task

Build a Network Response Handler

You are building a data layer for a weather app. You need to handle the various results of an API call. Instead of using nulls or throwing exceptions, you will use a sealed hierarchy to represent the outcome.

Requirements:

  • Create a sealed class named NetworkResult.
  • Create three subtypes:
    • Success: A data class that holds a data: String (the JSON response).
    • ApiError: A data class that holds an errorCode: Int.
    • NetworkError: An object (since it doesn't need unique data).
  • Write a function called handleResponse(result: NetworkResult) that uses a when expression to print a different message for each state:
    • Success: "Received data: [data]"
    • ApiError: "API failed with code: [errorCode]"
    • NetworkError: "No internet connection."
  • Ensure the when expression is exhaustive without using an else block.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.