skip to content

In a sealed hierarchy, when should a case be an object/data object versus a data class, and how does that map back to enum entries?

level: middleimportance: should knowfreq 45%

answer

  1. no data => data object (singleton = enum entry)
  2. has data => data class (many instances)
  3. data object: clean toString + equals/hashCode
  4. data class: copy(), componentN, value equality
  5. never make a payload case a singleton object

basics

~20 s

Use an object (or data object) when the case carries no data — it's a singleton like an enum entry. Use a data class when the case needs its own fields and may have many instances. data object adds a clean toString and equals.

solid answer

~40 s

Within a sealed type, a case with **no per-instance data** should be a singleton `object` — and preferably a `data object`, which generates a readable `toString()` (printing the name) plus structural `equals`/`hashCode`. These data-free singletons are the direct analogue of enum entries: exactly one instance for the whole program. A case that **carries data** must be a `data class`, because each value needs its own field values and you typically want many distinct instances (`Error("a")` vs `Error("b")`). The `data` modifier on the class also gives you `copy()`, component functions for destructuring, and value-based equality. So the mapping is: enum entry ~ `data object` (singleton, no payload); divergent payload case ~ `data class`. Mixing both in one sealed hierarchy is normal and idiomatic — e.g. `Loading` as a `data object`, `Success(data)`/`Error(e)` as data classes.

code

kotlin · 9 lines
kotlin
sealed interface UiState {
    data object Idle : UiState                       // singleton, like an enum entry
    data class Loaded(val rows: List<Int>) : UiState // payload, many instances
}

val a = UiState.Loaded(listOf(1))
val b = UiState.Loaded(listOf(1))
println(a == b)            // true: data class value equality
println(UiState.Idle)      // "Idle": data object toString

go deeper

for a junior

Knows object = no data, data class = has data, and that object cases are singletons.

for a middle

Explains data object's generated toString/equals, why payload cases need data class (many instances, value equality), and the enum-entry analogy.

for a senior

Discusses logging/serialization/equality implications and when plain class (reference identity) is deliberately chosen over data class.

for a principal

Reasons about the hierarchy's public contract — equality semantics, instance lifecycle, and how the object/data-class mix affects API stability and consumers.

## The choice inside a sealed type Each subtype of a `sealed interface`/`sealed class` is a normal declaration, so you pick its *kind*: ### `object` / `data object` — the singleton case Use when the case has **no data of its own**. There's only ever one meaningful value, so a singleton is correct and avoids allocating identical instances. - `object Loading : State` — one instance, referenced by name. - `data object Loading : State` — same singleton, but the compiler also generates `toString()` returning `"Loading"` and consistent `equals`/`hashCode`. Without `data`, `toString()` prints something like `State$Loading@1a2b`. `data object` (stable since Kotlin 1.9) is the idiomatic choice for stateless cases, especially when they appear in logs, equality checks, or serialized output. This is the closest thing to an **enum entry**: a named, single-instance constant. ### `data class` — the payload case Use when the case **carries its own properties** and you need distinct instances: ```kotlin sealed interface State { data object Loading : State data class Success(val items: List<String>) : State data class Error(val cause: Throwable) : State } ``` `data class` gives you: - a primary constructor holding the case's fields, - value-based `equals`/`hashCode` (two `Success` with equal items are equal), - `copy()` and `componentN()` for destructuring, - many instances, each with different data. ## Why not plain class? A non-data `class` works but loses value equality, `copy`, and destructuring — usually you want `data class` for payload cases. Use a plain `class` only if you deliberately want reference identity. ## Mapping back to enums - enum entry with no overridden data => `data object`. - enum 'entry' you wish could carry varied data => `data class`. A sealed hierarchy commonly mixes the two; that flexibility is the whole reason to choose sealed over enum. ## Gotcha Don't make a payload case an `object` — a singleton can hold only one fixed value, so you couldn't represent `Error(a)` and `Error(b)` separately. Conversely, don't make a stateless case a `data class` with no properties just out of habit; `data object` is clearer and a true singleton.

  • What does data object add over plain object?
    A readable toString() (the class name) and consistent structural equals/hashCode, instead of the default identity-based ones.
  • Could you use a data class with no properties for a stateless case?
    It compiles but is non-idiomatic and not a singleton — you'd allocate equal instances. Prefer data object for stateless cases.

data object is a one-of-a-kind landmark; a data class is a stamped form you can fill out many times.

saying these in an interview costs you the question

  • Modeling a payload-bearing case as an object singleton
  • Not knowing data object exists / using plain object and getting ugly toString
  • Claiming object cases can hold per-instance varying data
  • Saying data class and data object are interchangeable

context