Skip to Content
Course content

195: Practice Exercise: Refactoring to Use Sealed Classes for State

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

I've seen this pattern a hundred times in code reviews: a developer creates a state class for a screen, and it starts with one or two booleans. Then they realize they need an error message, so they add a nullable string. By the time the feature is "done," they have a data class that looks like a checklist of every possible thing that could happen, and the logic to handle it is a fragile tower of if-else statements.

The "Boolean Soup" Approach

Let's look at a common example. Imagine we're building a user profile screen. The naive way to track the state is to bundle everything into one class and use nulls or flags to signal what's currently happening:

data class ProfileState(
    val isLoading: Boolean = false,
    val user: User? = null,
    val error: String? = null
)

At first glance, this seems flexible. But here's the problem: you've just created a state space where "impossible" states are perfectly legal. What happens if isLoading is true, but user is also populated, and error contains a message? Which one wins? You'll find yourself writing code like if (state.isLoading) { ... } else if (state.error != null) { ... } else if (state.user != null) { ... }. This is where bugs hide. If you change the order of those if-statements, you might accidentally hide an error message because a stale user object was still hanging around in the state.

Making Impossible States Unrepresentable

The better way—and the way I want you to lean into—is using sealed classes. Instead of one class with many optional fields, we define a closed hierarchy where only one state can exist at a time. We aren't just organizing the data; we're using the type system to forbid the "Boolean Soup" I mentioned earlier.

sealed class ProfileState {
    object Loading : ProfileState()
    data class Success(val user: User) : ProfileState()
    data class Error(val message: String) : ProfileState()
}

Now, it is physically impossible to be in the Loading state and the Success state simultaneously. The User object doesn't even exist in the Loading or Error types. I love this approach because it moves the burden of correctness from my memory (or a long list of unit tests) to the compiler.

The Power of Exhaustive Checking

The real magic happens when you actually use this state in your UI logic. When you use a when expression with a sealed class, Kotlin knows exactly how many subclasses exist. If you forget to handle the Error state, the code won't even compile.

fun render(state: ProfileState) {
    when (state) {
        is ProfileState.Loading -> showSpinner()
        is ProfileState.Success -> showUser(state.user)
        is ProfileState.Error -> showError(state.message)
    }
}

Compare this to the naive approach where you'd probably have a trailing else block that just logs a "should not happen" error. With sealed classes, the else block becomes unnecessary. If I add a new state later—say, ProfileState.Empty for users with no data—the compiler will immediately point to every single when block in the entire project and tell me, "Hey, you forgot to handle the Empty state here." That's the kind of safety net that lets you refactor large codebases without the constant fear of breaking a random edge case.




📋 Practical Task

Refactor the Order Tracking State

You have been handed a legacy piece of code for a food delivery app. The OrderState is currently implemented using the "Boolean Soup" method, making the UI logic messy and error-prone. Your task is to refactor this into a sealed class hierarchy.

Current Naive Implementation:

data class OrderState(
    val isPending: Boolean = false,
    val isCooking: Boolean = false,
    val isOutForDelivery: Boolean = false,
    val isDelivered: Boolean = false,
    val orderId: String? = null,
    val driverName: String? = null,
    val errorMessage: String? = null
)

Your Requirements:

  • Create a sealed class named OrderState.
  • Define a Pending state (should contain the orderId).
  • Define a Cooking state (should contain the orderId).
  • Define a OutForDelivery state (should contain the orderId and driverName).
  • Define a Delivered state (should contain the orderId).
  • Define an Error state (should contain the errorMessage).
  • Implement a function handleOrderState(state: OrderState) that uses a when expression to print a specific message for each state (e.g., "Your order [id] is being cooked!").
Rating
0 0

There are no comments for now.

to be the first to leave a comment.