skip to content

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