Data, Sealed & Value Classes Explained

KOTLIN › Types & Classes

Interviewers reach for data classes, sealed hierarchies, and value classes to find out whether you actually know Kotlin's modeling primitives, or whether you're just writing Java with different keywords. Each of these has compiler-generated behavior and hard constraints, and knowing the exact rules is what separates someone who's used Kotlin from someone who's internalized it.

Start with the data class. It's Kotlin's tool for saying "this is just a bag of values, compare it and print it structurally, not by identity." Declare one and the compiler generates four things straight from the primary constructor: structural equals() and hashCode(), a readable toString() in the form ClassName(prop1=..., prop2=...), a copy() function that builds a new instance with some properties changed, and a family of componentN() functions, one per property, that power destructuring.

data class User(val name: String, val age: Int)

val a = User("Ada", 30)
val b = User("Ada", 30)
println(a == b) // true, structural equality
println(a)      // User(name=Ada, age=30)

Compare that to a plain class with the same body. There, a == b would be false, because it compares references, and printing a would give you something unreadable like User@1a2b3c. A **no-argument constructor** is not part of that generated set. It only shows up if every primary-constructor parameter happens to have a default value, which is a property of defaults, not something data class gives you for free.

All four of those generated members only look at properties declared in the **primary constructor**. A property declared in the class body is invisible to equals(), hashCode(), toString(), and copy(), even though it's a real property you can read and write on the instance.

data class Person(val name: String) {
    var age: Int = 0 // body property, excluded from equals/hashCode/toString
}

val a = Person("Alice").also { it.age = 30 }
val b = Person("Alice").also { it.age = 99 }
println(a == b) // true, age never enters the comparison

a and b have wildly different age values, but as far as the generated equals() is concerned they're identical, because age was never part of the primary constructor. This is a deliberate design choice: the primary constructor is the class's declared identity, and anything in the body is just extra state riding along on top of it. It also means componentN() functions are only generated for primary-constructor properties, a body property can never be destructured.

Because the primary constructor is the class's identity, Kotlin puts real constraints on how a data class can be declared. It needs **at least one** parameter, and every parameter must be val or var:

data class Point(val x: Int, val y: Int)   // OK
// data class Empty()                      // error: needs at least one property
// data class Coord(x: Int, y: Int)        // error: params must be val/var

A data class also can't be abstract, open, sealed, or inner. That's not an arbitrary restriction, it's consistency. If subclasses were allowed, a subclass instance and a base instance could end up comparing equal through the generated equals() despite having different runtime types and different fields, and copy() on a base reference wouldn't know how to reconstruct a subclass. So a data class is effectively final, and if you need a hierarchy of related types, that's what sealed classes are for.

copy() builds a new instance from an existing one, letting you override just the properties you name and carrying the rest over unchanged:

data class User(val name: String, val age: Int)
val a = User("Ada", 30)
val older = a.copy(age = 31) // only age changes, name is carried over

That's the everyday use. The gotcha is what "carried over" actually means for a property that holds a reference, like a MutableList. copy() is a **shallow** copy: for reference-typed properties, it copies the reference itself, not the object it points at.

data class User(val name: String, val tags: MutableList<String>)

val a = User("Ada", mutableListOf("admin"))
val b = a.copy()          // new User, but b.tags IS a.tags, same list object
b.tags.add("editor")
println(a.tags)           // [admin, editor], a changed too

b is a distinct User, but b.tags and a.tags are the identical MutableList, so mutating one is visible through the other. If you actually need an independent copy of that list, you have to build one yourself and pass it in explicitly: a.copy(tags = a.tags.toMutableList()) creates a fresh list with the same elements, so mutating the copy's list never touches the original's.

The componentN() functions data classes generate exist to power **destructuring**: val (a, b) = point. That desugars into calls to component1(), component2(), and so on, generated in **primary-constructor order**.

data class Point(val x: Int, val y: Int)

val p = Point(3, 7)
val (a, b) = p
// a = p.component1() = x = 3
// b = p.component2() = y = 7

The names you pick on the left, a and b here, are just local variable names. They have no connection to the property names x and y, destructuring is entirely positional. That has a real consequence: if you reorder a data class's constructor parameters, every existing destructuring call silently starts binding different values, with no compiler error to warn you.

A sealed class or sealed interface declares a **closed** set of subtypes. "Closed" has a precise meaning in Kotlin: every direct subclass must live in the **same module and package** as the sealed declaration itself, which is what lets the compiler enumerate the complete set at compile time.

sealed class Result
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()

A sealed class is implicitly abstract, you can never instantiate Result directly, only its subtypes, and its constructors are protected or private by default. If you tried to declare a fourth subtype of Result in a different package, that wouldn't compile, the whole point is that nothing outside the declaring module and package can extend the hierarchy.

That closed hierarchy is what makes when exhaustiveness checking possible. Because the compiler can see every direct subtype of a sealed type, a when expression over that type can skip the else branch entirely and still be verified complete:

fun render(r: Result) = when (r) {
    is Success -> show(r.data)
    is Error -> showError(r.message)
    Loading -> showSpinner()
    // no else needed, the compiler checked every subtype is handled
}

Add a new subtype to Result later, and every when over Result that doesn't handle it stops compiling. That's the real payoff: instead of a new case silently falling through some else branch at runtime, you get a compile error pointing at every place that needs updating. An open type, or a type from outside the sealed hierarchy, can't offer that guarantee, so when over those still needs else.

Everything above applies equally to sealed interface, with one extra capability a sealed class doesn't have. A class can only extend one superclass, so it can belong to just one sealed class hierarchy. But a class can implement several interfaces at once, so it can belong to **multiple sealed interface hierarchies simultaneously**.

sealed interface Clickable
sealed interface Focusable

class Button : Clickable, Focusable

fun handle(c: Clickable) = when (c) {
    is Button -> println("button clicked")
}

Button participates in both the Clickable closed set and the Focusable closed set at the same time, something impossible if both were sealed classes. The same-module-and-package rule still applies to every implementer, that part of "closed" doesn't relax just because it's an interface.

With sealed hierarchies in hand, the enum-versus-sealed decision comes down to one question: do all the cases share the exact same shape? An enum class fits a fixed set of constants that are structurally identical, and in exchange you get some things for free that sealed types don't have: every constant automatically carries ordinal, its position, name, and the whole set is enumerable through entries.

enum class Status { PENDING, ACTIVE, CLOSED }
println(Status.ACTIVE.ordinal) // 1
println(Status.ACTIVE.name)    // "ACTIVE"
println(Status.entries)        // [PENDING, ACTIVE, CLOSED]

A sealed hierarchy has no automatic equivalent of entries, if you need to list every case, you maintain that list yourself. Where a sealed type earns its keep is when the cases need to carry **different** data:

sealed class UiState {
    object Loading : UiState()
    data class Content(val items: List<String>) : UiState()
    data class Error(val code: Int, val message: String) : UiState()
}

Content carries a list, Error carries a code and a message, Loading carries nothing. No enum constant can vary its own shape like that, every constant of a given enum shares one constructor signature. So: same shape, fixed set, want ordinal and entries for free, reach for enum. Different data per case, reach for sealed. You can also cycle through an enum's cases using its ordinal against entries, or by writing an explicit when that maps each constant to the next one.

A value class wraps a single property with, usually, zero runtime overhead: at the call site it behaves like the raw underlying value, not a heap-allocated wrapper object. On the JVM, and so on Android, declaring one needs both the @JvmInline annotation and the value keyword. The older standalone inline class syntax is deprecated and value alone won't compile on its own there.

@JvmInline
value class UserId(val id: String)

fun greet(id: UserId) = println("Hello, ${id.id}")
greet(UserId("u-42")) // compiled call passes a raw String, no wrapper object

The class must hold **exactly one** property, initialized in the primary constructor. It's implicitly final and can implement interfaces, but it can never extend another class. Inside the body you can add computed properties, ones with a getter and no backing field, but you can't add a second stored property or a mutable var with its own backing field, that would break the single-value guarantee the whole feature depends on.

@JvmInline
value class Email(val address: String) {
    val domain: String get() = address.substringAfter('@') // OK, computed, no backing field
    // var label: String = "" // error: backing field not allowed
}

The zero-overhead promise from the last chunk is conditional, not absolute. A value class only stays inlined to its raw underlying value when it's used **directly as its own non-nullable type**. Three situations force the compiler to box it into a real wrapper object instead:

@JvmInline value class UserId(val id: String)

fun a(id: UserId) { }        // unboxed: direct, non-null use
fun b(id: UserId?) { }       // boxed: nullable

fun <T> c(value: T) { }
c<UserId>(UserId("x"))       // boxed: used as a generic type argument

Nullable positions, generic type arguments, and passing the value through an interface or supertype it implements all require a real object, because those positions need something the JVM can represent uniformly, and a raw String or Int can't stand in for "any type, possibly null." So if you store UserId values in a List<UserId>, or pass one where UserId? is expected, you're paying for an actual allocation, the same cost as a normal class. The type safety is unconditional either way, you still can't accidentally pass a raw String where a UserId is expected, but the zero-cost part only holds for the direct, non-null case.

Put the three together and you've got a decision framework, not just three unrelated features. Reach for a data class when you need a value-like bag of properties compared and printed structurally, and remember its equality only ever looks at the primary constructor, and copy() only ever does a shallow copy of what it carries over. Reach for sealed when you're modeling a closed set of cases that carry genuinely different data, and lean on the exhaustive when it buys you to catch missing cases at compile time instead of at runtime. Reach for enum instead when those cases are structurally identical constants, you get ordinal, name, and entries for free. Reach for a value class when you want a primitive wrapped for type safety, like a UserId that can't be confused with a plain String, and remember that promise of zero overhead only holds when it's used directly and non-nullably.

If an interviewer asks you to justify a modeling choice, the answer is never "because it's idiomatic", it's the specific mechanical reason: this hierarchy is closed so the compiler can prove exhaustiveness, this class is a value type so identity shouldn't matter, this wrapper needs to disappear at runtime so it has to be a single property used non-nullably. That's the level of precision that separates someone who's memorized Kotlin syntax from someone who understands what the compiler is actually doing for them.

Back to Data, Sealed & Value Classes