Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 006 / 100

Kotlin sealed classes: a practical Android & Kotlin guide

Model closed sets of states.

Expona Admin8 min read

Kotlin sealed classes: a practical Android & Kotlin guide

When you're building Android apps, you often find yourself managing a finite set of possible states—loading, success, or error. Kotlin sealed classes let you model these closed hierarchies in a type-safe way that the compiler can help you get right. In this guide, we'll explore how sealed classes work, why they're invaluable for UI state management, and how to pair them effectively with data classes for production-grade Android development.

Why sealed classes matter for state management

In traditional object-oriented code, inheritance is open by default. Any class can extend your base class, which makes sense for frameworks but causes problems when you're modeling a small, fixed set of cases. Sealed classes change that: they're restricted to a known set of subclasses defined in the same package or file. This constraint is exactly what you need when modeling domain concepts that shouldn't have unexpected subtypes appearing later.

Consider a screen that fetches user data. The network request can result in three states: it's loading, it succeeded with data, or it failed with an error. Without sealed classes, you might create an open interface and hope no one adds a fourth state that breaks your logic. With sealed classes, the compiler enforces that you've handled every possible case.

The benefit compounds across your codebase. Every time you write a when expression to handle these states, the compiler checks that you've covered all branches. Refactor your sealed class to add a new state, and your IDE will show you every place that needs updating. This moves bugs from runtime to compile time, which is where they belong.

Great for UI state: Loading, Success, Error

The most common use case we see in production Android apps is modeling screen state. Let's say you're building a user profile screen that needs to fetch data from an API.

kotlin
sealed class ProfileState {
    object Loading : ProfileState()
    data class Success(val user: User) : ProfileState()
    data class Error(val message: String) : ProfileState()
}

Your ViewModel holds a StateFlow<ProfileState> and updates it as the network request progresses. Your UI layer observes this flow and renders accordingly:

kotlin
viewModel.profileState.collect { state ->
    when (state) {
        is ProfileState.Loading -> {
            showLoadingSpinner()
        }
        is ProfileState.Success -> {
            displayUserInfo(state.user)
            hideLoadingSpinner()
        }
        is ProfileState.Error -> {
            showErrorMessage(state.message)
            hideLoadingSpinner()
        }
    }
}

Notice that each branch of the when is handling an exhaustive case. If you later add a state like NoInternet and forget to handle it in this collector, the code won't compile. That safety is powerful in a large codebase where state handling happens across many screens and fragments.

We often extend this pattern to include more granular states. A real-world e-commerce checkout flow, for instance, might have states for address validation, payment processing, order confirmation, and retry logic after failures. Sealed classes keep that complexity manageable and prevent logic errors from silently deploying to production.

when expressions are exhaustive

The compiler's exhaustiveness check is the heart of sealed classes. When you use a sealed class in a when expression, Kotlin tracks whether you've covered every subtype. If you haven't, your code won't compile.

Here's what happens if you try to omit a branch:

kotlin
when (state) {
    is ProfileState.Loading -> showSpinner()
    is ProfileState.Success -> showUser(state.user)
    // Missing ProfileState.Error - compilation fails
}

The compiler outputs: "when expression must be exhaustive, add necessary 'is Error' branch or 'else' clause." You can either add the missing branch or use an else clause, but else defeats the purpose. The whole point is to be explicit about every state.

This exhaustiveness check is a game-changer during refactoring. Suppose you add a new state to handle rate limiting:

kotlin
sealed class ProfileState {
    object Loading : ProfileState()
    data class Success(val user: User) : ProfileState()
    data class Error(val message: String) : ProfileState()
    object RateLimited : ProfileState()
}

Every when expression that handles ProfileState will immediately fail to compile. Your IDE shows you exactly which files need updates. Compare that to an open interface where a new subclass breaks things silently at runtime on some code path you forgot existed.

For larger sealed classes, you can also use else strategically when you genuinely don't need to distinguish further:

kotlin
when (state) {
    is ProfileState.Success -> displayUserInfo(state.user)
    else -> showPlaceholder()  // Handles Loading and Error together
}

But prefer explicit branches. It makes your intent clearer and future maintainers understand what cases you're grouping together.

Sealed interfaces for flexibility

Sealed classes are powerful, but sometimes you need a bit more flexibility. Enter sealed interfaces. A sealed interface lets multiple unrelated types conform to the same contract, which is useful when your state hierarchy spans different domains or inheritance chains.

Consider a scenario where you're modeling both network operations and local database queries. You might want all their result types to be handled uniformly:

kotlin
sealed interface Result<T> {
    data class Success<T>(val data: T) : Result<T>
    data class Failure<T>(val exception: Exception) : Result<T>
    class Loading<T> : Result<T>
}

// Network call returns Result<UserResponse>
// Database query also returns Result<UserResponse>
// Both are exhaustively handled by the same when expression

Sealed interfaces are also useful when you want to compose state types. Imagine a screen that's both loading data and validating a form:

kotlin
sealed interface LoadingState
object Idle : LoadingState
object InProgress : LoadingState
data class Failed(val error: String) : LoadingState

sealed interface ValidationState
object Valid : ValidationState
data class Invalid(val fieldErrors: Map<String, String>) : ValidationState

data class FormScreenState(
    val loading: LoadingState,
    val validation: ValidationState
)

This composition approach keeps concerns separate while maintaining type safety. Each sealed interface is small and focused, which makes the code easier to reason about and test.

Pair with data classes

The real power emerges when you combine sealed classes with data classes. Data classes give you automatic equals, hashCode, and toString implementations, which matter a lot when you're storing state in flows.

kotlin
sealed class CartState {
    object Empty : CartState()
    data class Loaded(
        val items: List<CartItem>,
        val subtotal: BigDecimal,
        val tax: BigDecimal
    ) : CartState()
    data class CheckoutInProgress(
        val orderId: String,
        val estimatedTime: Duration
    ) : CartState()
    data class CheckoutFailed(
        val items: List<CartItem>,
        val reason: String,
        val retryCount: Int
    ) : CartState()
}

Each state subtype is either a simple object (for stateless states like Empty) or a data class carrying the information relevant to that state. When you observe a StateFlow<CartState>, the data class equality check prevents unnecessary UI recompositions if the state value hasn't actually changed.

We typically structure data classes to hold only the information needed for that particular state. The CheckoutInProgress state knows the order ID and estimated time, but not the cart items—you already showed those in a previous state. This keeps the state object lean and forces you to think through what information each screen state actually needs.

One practical pattern we use: make sealed class constructors private if you want to control instantiation:

kotlin
sealed class ApiResponse<T> private constructor() {
    data class Success<T>(val data: T) : ApiResponse<T>()
    data class Error<T>(val code: Int, val message: String) : ApiResponse<T>()
    class Loading<T> : ApiResponse<T>()

    companion object {
        fun <T> success(data: T): ApiResponse<T> = Success(data)
        fun <T> error(code: Int, message: String): ApiResponse<T> = Error(code, message)
        fun <T> loading(): ApiResponse<T> = Loading()
    }
}

This pattern lets you add validation logic or transformation before creating instances. In a trading app, for instance, you might want to sanitize or normalize market data before wrapping it in a state object.

Common mistakes

We've seen a few patterns that cause trouble in production codebases:

  • Overusing else clauses. If you're reaching for else often, your sealed class is probably too broad. Split it into smaller, more focused types.
  • Storing too much in every state. Some teams create a single state data class with optional fields for every piece of information the screen might display. That defeats the benefit of sealed classes. Each state should carry only relevant data.
  • Mixing UI and domain concerns. Keep domain models separate from UI state. A sealed class in your API client doesn't belong in your ViewModel. Map it explicitly.
  • Forgetting about nullable fields in Error states. It's tempting to make an error state just hold a message string. But you often need the original exception for logging, or an error code for retry logic. Be explicit about what context you need.
  • Not testing state transitions. Sealed classes make state exhaustive, but they don't make state transitions correct. Write tests that verify your ViewModel produces the right state sequence as you interact with the UI.

Key takeaways

  • Sealed classes enforce compile-time exhaustiveness in when expressions, moving state bugs from runtime to the compiler.
  • Use sealed classes to model finite sets of states like Loading, Success, and Error in your UI layers.
  • Pair sealed classes with data classes to get automatic equality checks, which prevents unnecessary recompositions when observing flows.
  • Sealed interfaces offer flexibility when you need to model state hierarchies across unrelated types or compose multiple sealed types.
  • Keep each state type focused and carry only the information relevant to that specific state.
  • Structure your code so sealed class definitions are close to where they're used, typically in ViewModels or repository layers.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles