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.

Kotlin
Structural equality, copy and destructuring come for free.
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.

Kotlin
Body properties are invisible to the generated members.
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.

Kotlin
A mutable property silently corrupts a HashSet.
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")
Note: Declare data class properties as val unless you have a specific reason not to. Immutability plus copy gives you updates without breaking equality-based collections.

copy Is Shallow

Kotlin
copy duplicates references, not the objects behind them.
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.

Kotlin
A sealed result type with exhaustive handling.
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.

Note: Exhaustiveness is enforced when 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.
Kotlin
Sealed classes for state machines and typed errors.
// 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.

ConstructCases carry dataInstances per caseUse for
enum classSame fields for allExactly oneFixed constants: status, direction
sealed class/interfaceDifferent per caseManyResults, states, typed errors, ASTs
data classN/A — one shapeManyA single value type
value classOne wrapped valueManyType-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.

Kotlin
value class prevents mixing up same-typed identifiers.
@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
• Keep data class properties val; mutating one breaks HashSet and HashMap lookups.
• 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.