skip to content

Sealed for State Modeling (ADTs)

Modeling a UI or network state as a sealed hierarchy — Loading, Success with data, Error with a cause — is the standard Kotlin answer to representing a value that is one of several shapes. Interviewers ask for it because it forces every caller to handle every state at compile time.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

You need to represent a screen that can be loading, show data, or show an error. How would you model this with a sealed type, and why is that better than three nullable fields?

level: juniorimportance: must knowfreq 80%

answer

  1. One value = exactly one case (sum type)
  2. object for no payload, data class for payload
  3. Illegal states unrepresentable
  4. when forces handling every case
  5. data class gives equals for state diffing

basics

~10 s

Make a sealed type with three cases: Loading, Success holding the data, and Error holding a message. Only one case can exist at a time, so you can never have a half-loaded, half-errored screen.

solid answer

~40 s

Model the screen as a sealed hierarchy (sealed interface or sealed class) with one subtype per state: an `object Loading`, a `data class Success(val data: T)`, and a `data class Error(val message: String)`. This is an algebraic data type (a 'sum type'): the value is exactly one of the alternatives. The alternative — a single class with `isLoading`, nullable `data`, and nullable `error` — allows impossible combinations (loading AND error AND data all set) that the compiler can't catch. With a sealed type the illegal states are simply unrepresentable, and a `when` over the sealed type forces you to handle each case. Use `object` for stateless cases (no payload) and `data class` for cases that carry data, so you get free `equals`/`hashCode` for state comparison.

code

kotlin · 11 lines
kotlin
sealed interface ScreenState {
    data object Loading : ScreenState
    data class Success(val users: List<User>) : ScreenState
    data class Error(val message: String) : ScreenState
}

fun render(state: ScreenState): String = when (state) {
    ScreenState.Loading -> "Loading…"
    is ScreenState.Success -> "${state.users.size} users"
    is ScreenState.Error -> state.message
}

go deeper

for a junior

Can name the three cases and explain that only one exists at a time; writes the sealed type and a when over it.

for a middle

Articulates 'illegal states unrepresentable' and uses object vs data class correctly; mentions exhaustiveness.

for a senior

Discusses sealed interface vs class, payload minimalism, and equals for state diffing in Compose/StateFlow.

for a principal

Frames it as ADT/sum-type design, weighs case granularity vs. UI needs, and sets a team convention for state modeling.

## The problem: illegal states A UI screen is usually in exactly **one** of a small set of states. A naive model uses one class with several fields: ```kotlin data class ScreenState( val isLoading: Boolean = false, val data: List<User>? = null, val error: String? = null, ) ``` This lets you build nonsense: `isLoading = true` while `error != null` and `data != null`. Every reader must defensively check all three, and bugs hide in the combinations. ## Sealed types model a 'sum type' (ADT) An **algebraic data type (ADT)** is a type built by combining others. A **sum type** (also 'tagged union' or 'OR type') says: a value is *exactly one* of these named alternatives. Kotlin expresses this with `sealed`: ```kotlin sealed interface ScreenState { data object Loading : ScreenState data class Success(val users: List<User>) : ScreenState data class Error(val message: String) : ScreenState } ``` - `sealed` means the set of direct subtypes is **closed** and known at compile time (they must be in the same package/module). The compiler therefore knows the full list of states. - `data object Loading` is a stateless case (a singleton). `data` gives it a readable `toString()` and stable `equals`. - `data class Success`/`Error` carry a **payload**. `data class` auto-generates `equals`, `hashCode`, `copy`, and component functions, which matters when something (e.g. Compose, a `StateFlow`) compares old vs new state. ## Why it is better - **Illegal states unrepresentable**: you literally cannot construct a value that is both Loading and Error. - **Exhaustiveness**: a `when (state)` used as an expression must cover every subtype or it won't compile, so adding a new state surfaces every place that must change. ```kotlin val text = when (state) { ScreenState.Loading -> "Loading…" is ScreenState.Success -> "${state.users.size} users" is ScreenState.Error -> state.message } ``` Note the **smart cast**: after `is ScreenState.Success`, `state` is typed as `Success`, so `state.users` is accessible with no cast. ## When NOT to add a state Keep the case set minimal. An `Empty` state is often just `Success(emptyList())`; only add it if the UI genuinely differs.

  • Why use a sealed interface here instead of a sealed class?
    A sealed interface adds no state/constructor of its own and lets a subtype implement multiple interfaces. For a pure tag-union of states it is usually the lighter choice; use sealed class when the parent needs shared stored properties.
  • Should Loading carry a payload?
    Only if it genuinely needs one (e.g. a progress percentage). If stateless, keep it a data object singleton — allocating a new Loading instance each time is wasteful and breaks reference equality.

Like a traffic light: it is red OR yellow OR green — never two at once. Three booleans would let it be red and green together.

saying these in an interview costs you the question

  • Defending parallel nullable/boolean fields as 'simpler'
  • Making every case a data class, including stateless Loading
  • Thinking sealed types are only for enums of constants
  • Not knowing illegal states become unrepresentable
  • Allocating a new object for a stateless singleton case

context

open as a page

In a sealed state type, when should a case be a `data object` versus a `data class` with no meaningful fields, and how does that choice affect equality and StateFlow emissions?

level: middleimportance: must knowfreq 55%

basics

~20 s

If a state carries no data (like Loading), make it a single shared object. If it carries data, make it a data class. A shared object is always equal to itself, which keeps state comparisons and StateFlow updates predictable.

open as a page

Implement a reusable generic Result type as a sealed hierarchy with Success<T> and Error cases. How do generics interact with object subtypes, and how would you write a `map` that transforms the success value?

level: middleimportance: should knowfreq 70%

basics

~10 s

Make a sealed type Result<T> with Success holding a T and Error holding a throwable. Add a map function that, if it's Success, transforms the value, and if it's Error, passes it through unchanged.

open as a page

Designers want the list screen to keep showing old data while a refresh runs, and to show a refresh error as a banner without hiding the data. A flat sealed Loading/Success/Error model can't express 'data + refreshing' or 'data + error'. How do you redesign the state?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Split the state into two parts: the content (the data you already have) and the loading/refresh status. Then you can show old data and a 'refreshing' or 'error' badge at the same time, instead of forcing the screen into a single Loading or Error box.

open as a page

Across a codebase, sealed state types accumulate scattered `when (state)` blocks. A teammate proposes adding a `fold`/visitor-style method on the sealed type instead. When is encapsulating behaviour on the ADT worth it, and what do you lose?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

A fold is a single method that takes one function per case and returns a result, so callers don't write their own when each time. It's handy for common reductions, but plain when is clearer for one-off logic and keeps the data and behaviour separate.

open as a page