DEV Community

kavearhasi v
kavearhasi v

Posted on

Your Sealed Class Cookbook: 3 Production-Ready Android Recipes

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:

  1. 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
    }
    
  2. Expose this state from your ViewModel using a StateFlow.

    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.")
                }
            }
        }
    }
    
  3. 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:

  1. 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>
    }
    
  2. Have your data layer (e.g., a repository) return this Result type. The try-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)
            }
        }
    }
    
  3. 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.

  1. Keep your UiState sealed interface pure. It should only contain persistent screen state.

  2. For events, use a separate mechanism like a SharedFlow or Channel in your ViewModel.

    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
    }
    
  3. 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)