The Problem with Boolean Flags and Nullable States

We've all been there. You're building a UI component—maybe a screen that fetches data from a remote API—and you find yourself managing state with a handful of disconnected variables. You have a isLoading boolean, a data object that is nullable, and perhaps an errorMessage string.

This approach is what I call "State Fragmentation." It creates a massive surface area for bugs. What happens if isLoading is true, but data is also not null? Or what if errorMessage is present, but the UI thinks it's still in a loading state? These impossible states are the silent killers of complex Android or backend applications. They lead to race conditions and UI glitches that are notoriously difficult to reproduce in testing.

When I first moved from Java to Kotlin, the most significant shift in my mental model wasn't about null safety—it was about Representing State as Data. Instead of managing multiple independent variables, we should use Sealed Interfaces to define a closed set of mutually exclusive states.

The Solution: Sealed Interfaces

A sealed interface allows you to define a restricted hierarchy. Unlike a regular interface, the compiler knows every possible implementation of a sealed interface within the same package. This knowledge is the secret sauce that makes our code "exhaustively" safe.

Let's look at a real-world scenario: a User Profile screen. Instead of a mess of variables, we define a single source of truth.

/**
 * Represents the various states of a User Profile screen.
 * Using a sealed interface ensures that the UI can only ever be in 
 * exactly one of these states at any given time.
 */
sealed interface UserProfileState {
    // A simple object for the initial state before any action is taken
    object Idle : UserProfileState

    // Represents the loading state. We can include a message if needed.
    data class Loading(val message: String = "Fetching profile...") : UserProfileState

    // Represents a successful data fetch. We wrap our domain model here.
    data class Success(val user: UserProfile) : UserProfileState

    // Represents a failure state with specific error details.
    data class Error(val exception: Throwable) : UserProfileState
}

// A simple domain model for context
data class UserProfile(val id: String, val name: String, val email: String)

Why This Works: Exhaustive 'when' Expressions

The real magic happens when you consume this state. In Kotlin, when you use a when expression as an expression (i.e., assigning its result to a variable or returning it), the compiler enforces that you have handled every single branch of the sealed hierarchy. If you add a Empty state to your interface later but forget to update a ViewModel or a UI component, the code simply won't compile.

This turns a runtime crash into a compile-time task. It's the difference between finding a bug in production and finding it while you're still writing the code.

class UserProfileViewModel {
    // This would typically be a StateFlow in a real Android app
    private var _state: UserProfileState = UserProfileState.Idle
    val state: UserProfileState get() = _state

    fun loadUserProfile(userId: String) {
        _state = UserProfileState.Loading()
        
        try {
            // Simulate network call
            val user = fetchUserFromApi(userId)
            _state = UserProfileState.Success(user)
        } catch (e: Exception) {
            _state = UserProfileState.Error(e)
        }
    }

    private fun fetchUserFromApi(id: String): UserProfile {
        // Mocking a network delay
        Thread.sleep(1000)
        return UserProfile(id, "Jane Doe", "jane@example.com")
    }

    /**
     * This function demonstrates how the compiler helps us.
     * If we forget to handle Error or Loading, this won't compile.
     */
    fun renderUi() {
        val uiMessage = when (val currentState = _state) {
            is UserProfileState.Idle -> "Welcome! Please log in."
            is UserProfileState.Loading -> "Please wait: ${currentState.message}"
            is UserProfileState.Success -> "Hello, ${currentState.user.name}!"
            is UserProfileState.Error -> "Oops! Something went wrong: ${currentState.exception.message}"
        }
        println(uiMessage)
    }
}

Pro-Tips for Production Use

After years of implementing this pattern, here are a few things I've learned to keep in mind:

  • Prefer Sealed Interfaces over Sealed Classes: If your state doesn't need to hold shared logic or constructor properties, use an interface. It's more lightweight and allows your state objects to implement other interfaces if needed.
  • Avoid 'else' branches in 'when': It is incredibly tempting to add an else -> ... block to your when statements. Don't do it. Adding an else branch defeats the purpose of sealed hierarchies because it silences the compiler's ability to warn you when you add a new state. You want that compiler error; it's your safety net.
  • Keep States Granular: Don't try to pack too much into a single state. If a state has too many properties, it might be a sign that your UI is actually managing two different things at once.
Senior Dev Wisdom: Code is read much more often than it is written. By using sealed interfaces, you aren't just preventing bugs; you are documenting the possible lifecycle of your component for every other developer on your team.

Final Thoughts

Moving away from "Boolean soup" to structured, algebraic data types might feel like extra boilerplate at first. You have to write more classes and interfaces. However, the time you save during debugging and the confidence you gain during refactoring is immeasurable. In a professional environment, the goal isn't just to make code work; it's to make code predictable. Sealed interfaces are one of the best tools Kotlin provides to achieve that predictability.