Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Modeling Valid Application States with Kotlin Sealed Interfaces

Hello! In the previous lesson, you saw why an Activity or ViewModel may have to reconstruct a screen after recreation or process death. Reconstruction raises a different question: what values are allowed to describe that screen in the first place? A restored value is only useful if the rest of the app can interpret it without guessing which fields matter.

This lesson uses Kotlin’s sealed interfaces, data classes, and value classes to model a search screen. You’ll make mutually exclusive conditions distinct, give meaningful types to identifiers, and examine where Kotlin’s guarantees stop. These choices will carry forward into the reactive UI state and ViewModel work later in the course.


Start with the states the screen can actually display

Imagine an article search screen. A first draft might put every possible UI concern in one data class:

data class SearchUiState(
    val isLoading: Boolean,
    val articles: List<Article>,
    val errorMessage: String?
)

This is easy to update, but what does it mean when isLoading is true, articles is nonempty, and errorMessage is also non-null? Perhaps the articles are cached and a refresh failed. Perhaps it is a bug. The type cannot tell you.

Before choosing Kotlin syntax, decide what the product means. For a simple search with no cached-results display, we will distinguish five conditions: no search has been submitted, a search is in progress, it returned no matches, it returned matches, or it failed. In this design, results and failure cannot be displayed simultaneously. If the product later requires showing old results during a refresh, the model must change deliberately rather than relying on an accidental combination of fields.

Watch this short tour for the roles of data, value, and sealed types. Use it for orientation; we’ll examine their constraints more carefully below.

Every Kind of Class in Kotlin

Watch “Every Kind of Class in Kotlin” by Dave Leeds for a compact comparison of the three Kotlin tools used in this lesson.

Watch data and value classes, focusing on generated copy() and on distinct types for otherwise interchangeable arguments. Then watch sealed classes for the connection between a closed set of cases and an exhaustive when. Treat the video's performance discussion of value classes as a potential benefit, not a promise that a wrapper is never boxed.

The design question is more important than memorizing a preferred class kind: Are these properties present together, or are they alternatives? Properties present together belong naturally in a data class. Alternatives with different meanings or payloads are candidates for a sealed hierarchy.


Use a sealed interface for mutually exclusive cases

Here is a model for the five search conditions:

sealed interface SearchUiState {
    data object Idle : SearchUiState
    data object Loading : SearchUiState
    data object Empty : SearchUiState

    data class Results(val articles: List<Article>) : SearchUiState {
        init {
            require(articles.isNotEmpty()) { "Use Empty for no articles" }
        }
    }

    data class Failure(val message: String) : SearchUiState
}

Idle, Loading, and Empty carry no additional information, so each can be a singleton data object. Results and Failure carry different information, so each is a data class. The Results check preserves a useful distinction: a successful search with zero matches is Empty, not Results(emptyList()). Calling the latter constructor—or calling its generated copy() with an empty list—throws an IllegalArgumentException.

A sealed interface closes the set of direct implementations to those allowed by Kotlin’s module and package rules. Because the compiler knows the possible cases, it can check a when expression:

fun searchLabel(state: SearchUiState): String = when (state) {
    SearchUiState.Idle -> "Search for articles"
    SearchUiState.Loading -> "Searching"
    SearchUiState.Empty -> "No matches"
    is SearchUiState.Results -> "${state.articles.size} matches"
    is SearchUiState.Failure -> state.message
}

Inside the Results branch, Kotlin knows that articles exists. There is no meaningless attempt to read articles from Loading, and no else that silently absorbs a newly added case. If you add, say, a PermissionRequired state, this expression must be revisited before it compiles. That is valuable in a growing Android codebase: changing the state model exposes places that must decide how to handle the new state.

Read the Kotlin documentation for the precise sealed-hierarchy and exhaustiveness rules, and compare its loading/success/error example with our additional Idle and Empty cases.

Sealed classes and interfaces

Read Kotlin Help’s “Sealed classes and interfaces” to distinguish a closed set of cases from an ordinary interface, and to see why when can be exhaustive.

In the opening section, read the inheritance rule and the immediately following explanation of sealed interfaces. Under “Use sealed classes with when expression,” read the exhaustiveness explanation and the log(e: Error) example through its final branch. Finally, under “Use case scenarios” → “State management in UI applications,” read the UI introduction and its UIState/updateUI example, ending before “Payment method handling.” Notice that the example uses a sealed class; our sealed interface serves the same closed-case purpose without needing a shared constructor.

A sealed class is also appropriate when variants need a common constructor or inherited implementation. For this screen, the variants need only a common type, so an interface keeps the declaration lean. An enum works well for a fixed set of singleton values, but Results needs to carry a list and Failure a message; sealed variants can carry different payloads.

The model makes some invalid states unrepresentable: a single SearchUiState value cannot be both Loading and Failure. It does not prove that every message is helpful, every article is valid, or every runtime input is trustworthy. Those require their own decisions and checks.


Give payloads value semantics—and understand their limits

Let’s fill in the article type and give a result a meaningful update:

data class Article(
    val id: ArticleId,
    val title: String
)

val first = Article(ArticleId("a17"), "State on Android")
val second = first.copy(title = "Modeling State on Android")

A data class generates equals(), hashCode(), toString(), destructuring functions, and copy() from properties in its primary constructor. second is a new Article; first still has its original title. This is convenient when a screen state is replaced with a new value rather than modified in place.

But copy() is shallow. Consider what happens at a mutable boundary:

val source = mutableListOf(first)
val results = SearchUiState.Results(source)
val copied = results.copy()

source.add(second)
// results.articles and copied.articles both now see second.

The List<Article> type prevents mutation through that property; it does not make an externally owned mutable list immutable. Nor does copying Results recursively copy its list. When receiving mutable data from elsewhere, make a snapshot before publishing state—for example, construct Results(source.toList())—and avoid retaining a mutable alias to nested objects. A new list is still only a shallow snapshot of its elements.

There is another less visible boundary: a data-class property declared in the class body, rather than the primary constructor, is excluded from generated equality and copy(). If a field changes what the UI displays, accidentally excluding it can make two visibly different states compare equal. Keep the fields that define a state’s value in its primary constructor.

Data classes

Read Kotlin Help’s “Data classes” for the exact contract behind value-based comparison and copy(); these details matter when state is replaced or compared.

From the opening definition, read the generated-members explanation, including the list of generated functions. In “Properties declared in the class body,” read the equality boundary, including the Person example; stop before “Copying.” Then, in “Copying,” read from the User.copy() illustration through the mutable-list example, paying particular attention to shallow copying. Stop before “Data classes and destructuring declarations.”

This is a good reason to regard immutable UI state as a design property, not a keyword. val prevents reassignment of a property; a data class provides useful value operations; neither freezes every object reachable through that property.


Use a value class to distinguish domain values

The article ID above should not be an arbitrary string at every call site. A function that accepts several strings is easy to call in the wrong order, and a blank ID should not enter the article model unnoticed. A value class gives the ID its own type:

@JvmInline
value class ArticleId(val raw: String) {
    init {
        require(raw.isNotBlank()) { "Article ID cannot be blank" }
    }
}

fun openArticle(id: ArticleId) {
    // Use id.raw at a boundary that needs the underlying string.
}

openArticle(ArticleId("a17"))
// openArticle("a17") does not compile.

For a JVM-targeted Android app, the value class has a single immutable underlying property and uses @JvmInline. Unlike a regular data class containing a string, ArticleId is intended as a lightweight wrapper; depending on how it is used, the compiler may represent it using the underlying value. Nullable, generic, and interface contexts can require boxing, so type clarity and safety are the primary reasons to choose it, not an unconditional allocation claim.

The compiler prevents passing a plain String where ArticleId is required. The require check has a different job: it rejects a runtime value such as " " when an ArticleId is constructed. If later you add a distinct SearchQuery value class, it can also prevent swapping a query and an article ID even though both wrap strings. Use such wrappers for distinctions that matter to the domain; wrapping every string would add ceremony without necessarily clarifying the code.

Value classes do not decide where state survives. After the process death discussed last lesson, a new screen still needs to recover an ID from an appropriate saved or persistent source and reconstruct the ArticleId. This type helps validate and interpret the recovered value; it does not preserve that value by itself.


Choose the shape of the whole screen deliberately

Our sealed SearchUiState works well for mutually exclusive search outcomes. It is less convenient if every outcome must also carry independently editable draft query text. Putting draftQuery in each sealed variant would duplicate a common field and make a routine text update branch on the current outcome.

Instead, distinguish the independent screen field from the mutually exclusive search result:

data class SearchScreenState(
    val draftQuery: String,
    val result: SearchUiState
)

val current = SearchScreenState(
    draftQuery = "kotlin",
    result = SearchUiState.Loading
)

val edited = current.copy(draftQuery = "kotlin state")

The user can edit the draft while a request is loading or after one fails, so those combinations are meaningful. The outer data class makes that common update straightforward; the inner sealed interface still prevents contradictory result cases. If the product needs to display cached articles while refreshing, Results could explicitly carry refresh information, or the state model could separate cached content from request progress. Neither approach is automatically correct: the deciding question is which combinations the product genuinely supports.

The following video shows why putting all screen fields into separate sealed variants can make shared updates cumbersome. Its title takes a strong position; read it as a warning about a particular design, not a rule that sealed UI states are always wrong.

Sealed Classes for UI State are an ANTI-PATTERN - Here's why!

Watch “Sealed Classes for UI State are an ANTI-PATTERN - Here's why!” by Philipp Lackner for an alternative that combines an outer data class with sealed details.

Watch the alternative model. Focus on why shared fields move into a data class while only variant-specific details remain sealed. Compare that division with SearchScreenState and SearchUiState above; the right boundary depends on which properties are genuinely shared.

For a design review or interview, an explanation worth being able to give is: “These are the mutually exclusive cases; these fields vary independently; these constructor checks enforce the remaining invariants.” That is more informative than saying a screen “uses sealed classes.”


Key takeaways

A sealed interface represents a closed set of alternative states and enables exhaustive when expressions. Data classes hold the values within a case or across a screen and support value-based comparison and copying—but copying is shallow, and only primary-constructor properties participate in generated value behavior. Value classes distinguish domain concepts such as article IDs from ordinary strings; validation in their constructors can reject malformed runtime values.

Together, these tools let the type model express what a screen may mean. They do not eliminate the need to decide product behavior, validate inputs, or control mutable aliases. Next, the course turns to Kotlin type variance: how to make APIs that consume and produce these kinds of values safe for their callers.

Can't find a good explanation? Sign up and we'll make it for you

Sign up