Kotlin Data Classes and Sealed Classes Complete Guide
Two Kotlin features that together cover most data modelling: data classes for values compared by content, and sealed hierarchies for closed sets of alternatives checked exhaustively by the compiler.
A data class answers the question "what is this value?" by generating structural equality, a readable toString, and a copy method for immutable updates. A sealed class answers "which of these cases is it?" by restricting subclasses to a known set, which lets when verify that every case is handled.
Used together they replace a large amount of boilerplate and a whole category of runtime bug — the unhandled case.
What the Compiler Generates
Adding data to a class makes the compiler generate five members from the properties declared in the primary constructor: equals, hashCode, toString, copy, and componentN functions for destructuring.
data class User(
val id: Int,
val name: String,
val email: String,
)
val a = User(1, "Ada", "ada@example.com")
val b = User(1, "Ada", "ada@example.com")
// Structural equality — compares property values, not references
println(a == b) // true (a regular class would print false)
println(a === b) // false (still different objects)
println(a.hashCode() == b.hashCode()) // true
// Readable toString for logs and debugging
println(a) // User(id=1, name=Ada, email=ada@example.com)
// copy for immutable updates — change one field, keep the rest
val renamed = a.copy(name = "Ada Lovelace")
// Destructuring via the generated componentN functions
val (id, name, email) = a
println("$id: $name")
// Works in loops over collections of pairs or data classes
for ((key, value) in mapOf(1 to "one", 2 to "two")) {
println("$key=$value")
}
Because equality is structural, data classes behave correctly as Map keys and in Set membership — the thing that silently fails with a regular class whose equals was never overridden.
Three Behaviours That Surprise People
Only Primary Constructor Properties Count
The generated members use the properties declared in the primary constructor. A property declared in the class body is excluded from equals, hashCode, toString and copy.
data class Session(val token: String) {
// Declared in the body — NOT part of equals/hashCode/copy/toString
var lastSeen: Long = 0
}
val s1 = Session("abc").apply { lastSeen = 100 }
val s2 = Session("abc").apply { lastSeen = 999 }
println(s1 == s2) // true — lastSeen is ignored
println(s1) // Session(token=abc) — lastSeen not shown
println(s1.copy().lastSeen) // 0 — copy does not carry it over
Mutable Properties Break Hash-Based Collections
A var in the primary constructor participates in hashCode. Mutating it after the object has been placed in a HashSet or used as a HashMap key makes the entry unreachable, because it is now stored in the wrong bucket.
data class Tag(var label: String)
val tag = Tag("draft")
val set = hashSetOf(tag)
println(set.contains(tag)) // true
tag.label = "published" // hashCode changes
println(set.contains(tag)) // false — the object is in the set
println(set.size) // 1 — but can no longer be found
// Fix: keep data class properties val, and use copy() to change them
data class SafeTag(val label: String)
val updated = SafeTag("draft").copy(label = "published")
val unless you have a specific reason not to. Immutability plus copy gives you updates without breaking equality-based collections.copy Is Shallow
data class Team(val name: String, val members: MutableList<String>)
val original = Team("core", mutableListOf("ada"))
val duplicate = original.copy(name = "platform")
duplicate.members.add("grace")
println(original.members) // [ada, grace] — the SAME list
// Fix: use immutable collection types in the first place
data class SafeTeam(val name: String, val members: List<String>)
val safe = SafeTeam("core", listOf("ada"))
val grown = safe.copy(members = safe.members + "grace")
Also worth knowing: data classes cannot be open or abstract, and inheritance interacts badly with generated equality. If you find yourself wanting to subclass a data class, a sealed hierarchy is almost certainly the right structure instead.
A Closed Set of Alternatives
A sealed class restricts its subclasses to those declared in the same compilation unit. Because the compiler knows the complete list, a when over a sealed type can be verified exhaustive — and a when used as an expression is required to be.
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Failure(val code: Int, val message: String) : ApiResult<Nothing>
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
data object Loading : ApiResult<Nothing>
}
fun <T> render(result: ApiResult<T>): String = when (result) {
is ApiResult.Success -> "Loaded ${result.data}"
is ApiResult.Failure -> "Error ${result.code}: ${result.message}"
is ApiResult.NetworkError -> "Offline: ${result.cause.message}"
ApiResult.Loading -> "Loading..."
// No `else` branch needed — and adding a new subtype
// makes this when fail to compile until it is handled.
}
That last comment is the real value. Add a Timeout case to the hierarchy and every exhaustive when across the codebase becomes a compile error listing exactly what needs updating. The alternative — an else branch — silently absorbs the new case at runtime.
when is used as an expression (its value is used). As a statement, a non-exhaustive when is a warning rather than an error, so prefer the expression form for sealed types.// A state machine where illegal states cannot be represented
sealed class Checkout {
data object Empty : Checkout()
data class Cart(val items: List<Item>) : Checkout()
data class Paying(val items: List<Item>, val method: String) : Checkout()
data class Done(val orderId: Int) : Checkout()
}
fun next(state: Checkout, event: Event): Checkout = when (state) {
is Checkout.Empty -> if (event is Event.Add) Checkout.Cart(listOf(event.item)) else state
is Checkout.Cart -> when (event) {
is Event.Add -> state.copy(items = state.items + event.item)
is Event.Pay -> Checkout.Paying(state.items, event.method)
else -> state
}
is Checkout.Paying -> if (event is Event.Confirm) Checkout.Done(event.orderId) else state
is Checkout.Done -> state
}
// data object (Kotlin 1.9+) gives singletons a proper toString
// and equals, unlike a plain `object`.
Matching the Tool to the Shape of the Data
All three model "one of several things", but they differ in whether the cases carry data and whether the set is fixed.
| Construct | Cases carry data | Instances per case | Use for |
|---|---|---|---|
enum class | Same fields for all | Exactly one | Fixed constants: status, direction |
sealed class/interface | Different per case | Many | Results, states, typed errors, ASTs |
data class | N/A — one shape | Many | A single value type |
value class | One wrapped value | Many | Type-safe ids with no allocation |
The deciding question is whether the alternatives need different data. An enum forces every case to share the same fields; a sealed hierarchy lets Success carry a payload while Failure carries an error code.
@JvmInline
value class UserId(val value: Int)
@JvmInline
value class OrderId(val value: Int)
// Both are Int at runtime — no wrapper allocation in most cases —
// but the compiler will not let you swap them.
fun loadOrder(userId: UserId, orderId: OrderId) { /* ... */ }
val u = UserId(1)
val o = OrderId(2)
loadOrder(u, o) // fine
// loadOrder(o, u) // Error: type mismatch — a real bug caught
• Only primary constructor properties appear in equals, hashCode, toString and copy.
• copy is shallow — prefer immutable collection types inside data classes.
• Use sealed types when cases carry different data; enums when they do not.
• Use when as an expression over sealed types to get compile-time exhaustiveness.
• Avoid else over a sealed type — it hides newly added cases.
• Use value class for identifiers to stop same-typed arguments being swapped.
Summary
Data classes give you correct structural equality, readable output and immutable updates from a single keyword — provided the properties are val and hold immutable types, which is what keeps equality stable and copy meaningful.
Sealed classes and interfaces make the set of alternatives closed, which turns when into an exhaustiveness check. Adding a case then produces a compile-time list of every site that must handle it, instead of a runtime surprise.
Combined — a sealed hierarchy whose cases are data classes — they express "one of these shapes, each with its own data" precisely, and make whole categories of invalid state impossible to construct.