Mastering Kotlin Sealed Interfaces for Type-Safe State Management
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 usesealed interfacesoversealed classesunless you specifically need to hold constructor state in the base class. Interfaces are more lightweight and allow your data models to implement other interfaces (likeParcelableorSerializable) 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 aSearchState.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
whenexpression. Avoid usingelse ->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.