LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 1017 min read

Data Classes & Sealed Classes

Learn how data classes auto-generate equals, hashCode, toString, and copy, and how sealed classes model restricted class hierarchies.

Introduction

A huge amount of everyday code exists purely to hold data — request/response objects, database rows, configuration values. Kotlin has a dedicated class type, `data class`, that eliminates the boilerplate this normally requires in Java. Alongside it, `sealed class` solves a different problem: modeling a fixed, known set of possible types, so the compiler can guarantee you've handled every case.

This lesson covers what data classes generate for you automatically, the destructuring syntax they enable, and how sealed classes work together with `when` to make illegal states unrepresentable.

Declaring a Data Class

Adding the `data` modifier before `class` tells the compiler to automatically generate several standard methods based on the properties declared in the primary constructor.

data class User(val name: String, val email: String, val age: Int)
fun main() {
val user = User("Leah", "leah@example.com", 27)
println(user) // uses the auto-generated toString()
}
Output

Click Run to see what this code prints.

Auto-Generated equals, hashCode, toString

Without `data`, a plain Kotlin class compares by reference identity, exactly like Java — two objects with identical field values are still considered "not equal" unless you write `equals()`/`hashCode()` yourself. `data class` generates structural equality automatically, based on every property in the primary constructor.

class PlainUser(val name: String)
data class DataUser(val name: String)
fun main() {
val p1 = PlainUser("Kai")
val p2 = PlainUser("Kai")
println(p1 == p2) // false — compares references
val d1 = DataUser("Kai")
val d2 = DataUser("Kai")
println(d1 == d2) // true — compares property values
}
Output

Click Run to see what this code prints.

Only Primary-Constructor Properties Count

equals(), hashCode(), and toString() are generated only from properties declared in the primary constructor. Properties declared in the class body are ignored by these generated methods — worth remembering if a data class isn't comparing the way you expect.

The copy() Function

Data classes also generate a `copy()` function, which creates a new instance with the same property values as the original, except for any properties you explicitly override. This is especially valuable for working with immutable (`val`-only) data classes, where you can't just mutate a field directly.

data class User(val name: String, val email: String, val age: Int)
fun main() {
val original = User("Leah", "leah@example.com", 27)
val birthday = original.copy(age = 28)
println(original)
println(birthday)
}
Output

Click Run to see what this code prints.

Destructuring Declarations

Data classes also generate `componentN()` functions for each primary-constructor property, which enables destructuring — unpacking an object's properties directly into separate variables in one line.

data class Point(val x: Int, val y: Int)
fun main() {
val point = Point(3, 7)
val (x, y) = point // destructuring declaration
println("x=$x, y=$y")
}
Output

Click Run to see what this code prints.

What Sealed Classes Solve

Sometimes you want to model a value that can only ever be one of a fixed, known set of types — for example, the result of a network call being either a success or one of a couple of specific failure types. A plain interface lets any class implement it, anywhere, so the compiler can never be sure it has seen every possible case. A `sealed class` (or `sealed interface`) restricts all subclasses to be defined in the same file (or module), which lets the compiler verify a `when` expression covers every possibility.

Declaring a Sealed Class

sealed class ApiResult
data class Success(val data: String) : ApiResult()
data class Error(val message: String) : ApiResult()
object Loading : ApiResult()
fun describe(result: ApiResult): String = when (result) {
is Success -> "Got data: ${result.data}"
is Error -> "Failed: ${result.message}"
Loading -> "Still loading..."
// no else branch needed — the compiler knows these are the only options
}
fun main() {
println(describe(Success("42 users")))
println(describe(Error("Timeout")))
println(describe(Loading))
}
Output

Click Run to see what this code prints.

Exhaustiveness Checking

Because ApiResult is sealed, the compiler knows Success, Error, and Loading are the only possible subtypes. If you later add a new subclass and forget to update the when expression, the compiler raises an error instead of silently falling through — this is a huge advantage over a plain open class or interface.

Sealed Classes vs Enum Classes

An `enum class` also represents a fixed set of options, but every enum constant must be the exact same type with the same properties. A sealed class hierarchy allows each subtype to carry completely different data (as `Success` and `Error` do above), making it a better fit whenever the different cases need different shapes of data, not just different names.

Featureenum classsealed class
Fixed set of optionsYesYes
Each case can hold different dataNo — all constants share the same shapeYes — each subclass can have its own properties
Exhaustive when checkingYesYes
Typical use caseSimple fixed categories (Status.ACTIVE, Status.INACTIVE)Modeling outcomes/results with different payloads per case

Common Mistakes

Avoid These Mistakes
  • Putting mutable `var` properties outside the primary constructor and expecting them to be included in equals()/toString() — they are not.
  • Using a plain open class with subclasses spread across multiple files when you actually want exhaustive `when` checking — that requires `sealed`.
  • Adding an unnecessary `else` branch to a `when` over a sealed class — it silently defeats the compiler's exhaustiveness check when a new subtype is added later.
  • Reaching for an enum class when different cases genuinely need different data — that is exactly what sealed classes are for.

Best Practices

  • Use `data class` for any type whose main purpose is to hold values — DTOs, API responses, simple records.
  • Prefer `copy()` over manually constructing a near-identical new instance when only one or two fields change.
  • Model finite sets of outcomes (success/error/loading, payment states, parsing results) with sealed classes instead of booleans or string flags.
  • Let the compiler enforce exhaustiveness — avoid adding a defensive `else` branch to a `when` over a sealed type unless you genuinely want to silence future additions.

Frequently Asked Questions

Yes, but the parent must not itself be a data class, and Kotlin still restricts data classes from having certain other modifiers (like being abstract, open, or sealed themselves).

No. They can be data classes, plain classes, or objects (for cases with no extra data, like Loading above) — whatever shape best fits each case.

They work almost identically for exhaustiveness checking; the difference is that a class implementing a sealed interface can still extend a separate open class, since Kotlin allows multiple interface implementation but only single class inheritance.

Key Takeaways

  • `data class` automatically generates equals(), hashCode(), toString(), copy(), and componentN() functions from primary-constructor properties.
  • Structural equality (`==` compares values) is automatic for data classes, unlike plain classes which compare by reference.
  • `copy()` creates a modified duplicate, ideal for working with immutable, `val`-only data classes.
  • `sealed class` restricts a type hierarchy to a known, fixed set of subclasses, enabling exhaustive `when` checking.
  • Use sealed classes over enums whenever each case needs to carry different data.

Summary

You now know how to model data cleanly with data classes and restricted hierarchies with sealed classes — two of Kotlin's most idiomatic patterns. Next, you'll learn the basics of Kotlin coroutines for writing asynchronous code.

Next Lesson →

Coroutines Basics