The Problem with Traditional Enums and Interfaces

I've spent a lot of time refactoring legacy Android and Backend modules where state was managed using a mix of nullable properties and boolean flags. You've likely seen it: a LoadingState class with isLoading: Boolean, errorMessage: String?, and data: List?. The problem here is the impossible state. What happens if both isLoading is true and errorMessage is not null? The compiler can't help you, and you end up writing defensive null-checks everywhere.

While sealed classes solved a lot of this, sealed interfaces (introduced in Kotlin 1.5) take this a step further by allowing a class to implement multiple sealed hierarchies, providing much more flexibility in how we model our domain.

Real-World Scenario: UI State Machine

Imagine you're building a complex search screen. The screen can be in several states: initializing, showing results, showing an empty state, or handling an error. However, some of these states might also be "refreshing" (loading new data while still showing old data). This is where a single sealed class hierarchy becomes rigid.

// Define a sealed interface for the primary screen state
sealed interface SearchState {
    object Initializing : SearchState
    data class Success(val results: List) : SearchState
    data class Error(val message: String, val exception: Throwable) : SearchState
    object Empty : SearchState
}

// Define a separate interface for loading behavior
sealed interface LoadingStatus {
    object Idle : LoadingStatus
    object Loading : LoadingStatus
}

// A composite state that brings them together
data class SearchViewState(
    val screenState: SearchState,
    val loadingStatus: LoadingStatus
)

Why This Approach Works

By separating the what (SearchState) from the how (LoadingStatus), we avoid a combinatorial explosion of classes. Instead of creating SuccessLoading, SuccessIdle, ErrorLoading, and ErrorIdle, we compose them.

The real magic happens in the when expression. Because these are sealed, the Kotlin compiler enforces exhaustiveness. If I add a new state like MaintenanceMode to SearchState, the code won't compile until I handle that case in every when block across the app.

Pro Tip: Always use sealed interfaces over sealed classes unless you specifically need to hold constructor state in the base class. Interfaces are more lightweight and allow your data models to implement other interfaces (like Parcelable or Serializable) without restriction.

Production-Ready Implementation

Here is how I typically implement this in a ViewModel or a Reducer pattern to ensure the UI remains a pure function of the state.

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class SearchViewModel {
    // Private mutable state, public immutable flow
    private val _uiState = MutableStateFlow(SearchViewState(
        screenState = SearchState.Initializing, 
        loadingStatus = LoadingStatus.Idle
    ))
    val uiState: StateFlow<SearchViewState> = _uiState.asStateFlow()

    fun performSearch(query: String) {
        // Update status to loading while keeping the previous screen state
        updateLoading(LoadingStatus.Loading)
        
        try {
            val results = api.search(query)
            if (results.isEmpty()) {
                updateScreenState(SearchState.Empty)
            } else {
                updateScreenState(SearchState.Success(results))
            }
        } catch (e: Exception) {
            updateScreenState(SearchState.Error("Search failed", e))
        } finally {
            updateLoading(LoadingStatus.Idle)
        }
    }

    private fun updateScreenState(newState: SearchState) {
        _uiState.value = _uiState.value.copy(screenState = newState)
    }

    private fun updateLoading(status: LoadingStatus) {
        _uiState.value = _uiState.value.copy(loadingStatus = status)
    }
}

Key Takeaways for Your Workflow

  • Eliminate Nulls: Instead of User?, use a SearchState.Success(user). This moves the null-check from a runtime risk to a compile-time requirement.
  • Composition over Inheritance: Use multiple sealed interfaces to represent different dimensions of your state (e.g., ConnectivityState, AuthState, DataState).
  • Exhaustive Switching: Lean on the when expression. Avoid using else -> blocks in your state handlers; this forces you to explicitly handle new states when the business requirements change.

This pattern transforms your state management from a series of "if-this-then-that" guesses into a rigorous mathematical model. It reduces bugs, simplifies testing, and makes the code self-documenting for anyone else joining your project.