Over the last four posts, we've explored the theory and power of Kotlin's sealed hierarchies. We've seen how they help us escape common pitfalls and how the compiler can become our safety net.
Now, it's time to get practical.
Theory is great, but the real test of any feature is how it performs in the trenches of day-to-day development. In this final post, I'm sharing my three go-to, production-ready "recipes" for using sealed hierarchies. These are the patterns I use constantly to build clean, robust, and modern Android applications.
Recipe 1: The Bulletproof UI State
In modern Android development with Jetpack Compose, your UI is simply a function of its state: UI = f(State)
. The biggest source of UI bugs comes from allowing for impossible states—like trying to show a loading spinner and an error message at the same time. A sealed hierarchy makes that entire class of bugs unrepresentable.
The Goal: Model all possible states of a screen so it can only be in one valid state at a time.
The Recipe:
-
Define a
sealed interface
for your UI state. This represents every major mode your screen can be in.
// In your ViewModel file sealed interface NewsUiState { object Loading : NewsUiState data class Success(val articles: List<Article>) : NewsUiState data class Error(val message: String) : NewsUiState }
-
Expose this state from your
ViewModel
using aStateFlow
.
class NewsViewModel : ViewModel() { private val _uiState = MutableStateFlow<NewsUiState>(NewsUiState.Loading) val uiState: StateFlow<NewsUiState> = _uiState fun loadNews() { viewModelScope.launch { _uiState.value = NewsUiState.Loading try { val articles = repository.fetchNews() _uiState.value = NewsUiState.Success(articles) } catch (e: Exception) { _uiState.value = NewsUiState.Error("Failed to load news.") } } } }
-
In your Composable, collect the state and use
when
to render the UI. The compiler guarantees you've handled every case.
@Composable fun NewsScreen(viewModel: NewsViewModel) { val uiState by viewModel.uiState.collectAsState() when (val state = uiState) { is NewsUiState.Loading -> LoadingSpinner() is NewsUiState.Success -> ArticleList(articles = state.articles) is NewsUiState.Error -> ErrorMessage(message = state.message) } }
Why it Works: This pattern provides a strong compile-time guarantee that your UI logic is complete. It's impossible for the UI to be in a contradictory state because the sealed contract only allows one state at a time.
Recipe 2: Resilient Error Handling with the Result
Type
Not all errors are exceptional. A lost network connection is a predictable failure, not a catastrophic crash. Using try-catch
blocks for control flow can make your code messy and hard to follow. A better way is to make success and failure explicit parts of your function's return signature.
The Goal: Handle predictable errors in a type-safe way without relying on exceptions for control flow.
The Recipe:
-
Define a generic
Result
sealed interface. This can be a top-level utility in your project.
sealed interface Result<out T> { data class Success<T>(val data: T) : Result<T> data class Failure(val exception: Throwable) : Result<Nothing> }
-
Have your data layer (e.g., a repository) return this
Result
type. Thetry-catch
becomes a clean implementation detail inside the function.
class UserRepository(private val api: UserApi) { suspend fun fetchUser(id: String): Result<User> { return try { val user = api.getUser(id) Result.Success(user) } catch (e: IOException) { // A network error is a predictable failure, not an exception to be thrown Result.Failure(e) } } }
-
The calling code (e.g., your
ViewModel
) is now forced by the compiler to handle both success and failure.
viewModelScope.launch { when (val result = userRepository.fetchUser("123")) { is Result.Success -> _uiState.value = UiState.Success(result.data) is Result.Failure -> _uiState.value = UiState.Error("Could not fetch user.") } }
Why it Works: This pattern makes your functions more honest about their outcomes. It turns runtime exceptions into predictable, compile-time checked results, leading to far more resilient and predictable application logic.
The Anti-Pattern to Avoid: The "Event as State" Trap
The power of sealed hierarchies can sometimes lead developers to overuse them. One of the most common mistakes I see is modeling one-time events as if they were persistent state.
The Problem: You want to show a Toast
or navigate to a new screen. A developer might add object ShowToast : UiState
to their state interface. This is a trap. State is persistent; it describes *what is. An event is a **one-time action; it describes *what happened**. When you model an event as state, it will re-fire on every configuration change or recomposition, leading to the toast showing up again and again.
The Right Recipe: Separate your state and your events.
Keep your
UiState
sealed interface pure. It should only contain persistent screen state.-
For events, use a separate mechanism like a
SharedFlow
orChannel
in yourViewModel
.
class MyViewModel : ViewModel() { private val _uiState = MutableStateFlow<UiState>(UiState.Loading) val uiState: StateFlow<UiState> = _uiState // Use a Channel or SharedFlow for one-time events private val _events = Channel<Event>() val events = _events.receiveAsFlow() fun onSomethingClicked() { viewModelScope.launch { _events.send(Event.ShowToast("Action complete!")) } } } sealed interface Event { data class ShowToast(val message: String): Event }
-
In your UI, use a
LaunchedEffect
to listen for these events.
// In your Composable LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { is Event.ShowToast -> { Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } } } }
Why it Works: This separation of concerns ensures your state remains predictable while allowing you to reliably trigger one-time actions in the UI.
This concludes our series on sealed hierarchies. We've gone from the fundamental "why" to these practical, everyday applications. By mastering these patterns, you can build apps that are safer, easier to reason about, and more maintainable.
What other patterns using sealed classes or interfaces have you found useful in your projects? Share them in the comments!
Top comments (0)