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?
answer
- One value = exactly one case (sum type)
- object for no payload, data class for payload
- Illegal states unrepresentable
- when forces handling every case
- data class gives equals for state diffing
basics
~10 sMake 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 sModel 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 linessealed 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
Can name the three cases and explain that only one exists at a time; writes the sealed type and a when over it.
Articulates 'illegal states unrepresentable' and uses object vs data class correctly; mentions exhaustiveness.
Discusses sealed interface vs class, payload minimalism, and equals for state diffing in Compose/StateFlow.
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