Skip to content
CalliCoder

Introduction to Data Classes in Kotlin

Published Updated Kotlin 11 min read

What the compiler generates, why copy() is the immutability tool, how destructuring depends on declaration order, and the three rules that decide which properties count for equals.

A class whose job is to hold values needs equals, hashCode, toString and some way to make a modified copy. In Java that is fifty lines you did not write by hand, generated by the IDE and then subtly wrong the day someone adds a field and forgets to regenerate.

Kotlin’s data modifier makes the compiler responsible for all of it:

data class Note(val title: String, val content: String, val tags: List<String> = emptyList())

Written against Kotlin 1.9.

What the compiler generates

Four things, derived from the properties declared in the primary constructor:

  • equals(other): component-wise comparison
  • hashCode(), consistent with that equals - toString(), Note(title=Shopping, content=milk, tags=[home])
  • componentN() — one per property, in declaration order, which is what enables destructuring

Plus copy(), which is the one that changes how you write code.

val a = Note("Shopping", "milk")
val b = Note("Shopping", "milk")

println(a == b)          // true  — structural equality
println(a === b)         // false — different objects
println(a)               // Note(title=Shopping, content=milk, tags=[])

== calls equals in Kotlin; === is reference identity. Without data, == on two identical notes would be false, because the default equals is reference comparison.

The rule that surprises everyone

Only properties in the primary constructor participate. A property declared in the body is invisible to all four generated methods:

data class User(val email: String) {
    var lastLogin: Instant? = null      // NOT part of equals, hashCode, toString or copy
}

val u1 = User("[email protected]").apply { lastLogin = Instant.now() }
val u2 = User("[email protected]")

println(u1 == u2)        // true — lastLogin is ignored
println(u1)              // User([email protected]) — no lastLogin
println(u1.copy().lastLogin)   // null — copy() did not carry it

That is a design decision rather than an oversight: the constructor defines identity, the body holds derived or incidental state. It is also a real source of confusion, and the fix is to put anything that matters for equality in the constructor.

copy() and immutability

Declare properties val and the instance cannot change. copy() is how you get a modified value without mutation:

data class Note(val id: Long, val title: String, val content: String, val published: Boolean = false)

val draft = Note(1, "Shopping", "milk")
val renamed = draft.copy(title = "Groceries")
val live = draft.copy(published = true)

println(draft)     // Note(id=1, title=Shopping, content=milk, published=false) — untouched

Named arguments mean you name only what changes, and the rest is carried over. That is the whole pattern for immutable updates, and it composes:

val result = note
    .copy(title = note.title.trim())
    .copy(published = true)

Two caveats worth knowing.

copy() is shallow. A mutable collection or object inside the class is shared with the copy:

data class Basket(val items: MutableList<String>)

val a = Basket(mutableListOf("apple"))
val b = a.copy()
b.items.add("pear")
println(a.items)          // [apple, pear] — the same list

Use immutable types inside (List, not MutableList) and the problem disappears. List in Kotlin is read-only at the type level, which is what makes copy() safe by default.

copy() ignores init validation on the copied path only in the sense that it does run the constructor, so a require in init still applies, which is usually what you want:

data class Percentage(val value: Int) {
    init {
        require(value in 0..100) { "percentage must be 0..100, was $value" }
    }
}

Percentage(50).copy(value = 150)   // throws IllegalArgumentException

Destructuring

componentN() lets a data class be unpacked positionally:

val (id, title, content) = note
println("$id: $title")

for ((key, value) in mapOf("a" to 1)) { ... }   // Map.Entry is destructurable

notes.forEach { (id, title) -> println("$id $title") }   // in a lambda parameter

Underscore skips a component you do not need:

val (_, title, _) = note

Destructuring is positional, not by name. This is the sharp edge:

data class Point(val x: Int, val y: Int)
val (x, y) = Point(1, 2)     // x = 1, y = 2

Reorder the constructor properties later and every destructuring site silently swaps its values. Nothing fails to compile, because both are Int. For that reason, prefer property access for anything with more than two or three components of the same type, and treat the constructor order of a widely destructured data class as part of its public API.

The requirements

The primary constructor must have at least one parameter, and every parameter must be val or var. Beyond that:

data class Ok(val a: String)

// error: data class must have at least one primary constructor parameter
data class Bad1

// error: data class primary constructor parameters must be val or var
data class Bad2(a: String)

A data class cannot be abstract, open, sealed or inner. It can implement interfaces, and it can extend a class, though inheritance plus generated equals is a combination worth avoiding — the generated equals compares only its own components, so a subclass instance can equal a superclass instance in ways nobody intended.

You can override any of the generated members. If you write your own equals, write hashCode too:

data class CaseInsensitiveTag(val name: String) {
    override fun equals(other: Any?) =
        other is CaseInsensitiveTag && name.equals(other.name, ignoreCase = true)

    override fun hashCode() = name.lowercase().hashCode()
}

Data class or value class

For a single wrapped value, @JvmInline value class avoids the allocation entirely, the wrapper exists at compile time and the underlying value is used at runtime:

@JvmInline
value class Email(val address: String) {
    init { require("@" in address) }
}

That gives type safety with no runtime cost, which a data class of one property does not. Use a data class from two properties up, or when you need copy() and destructuring.

Java records, for comparison

Java 16’s records cover the same ground:

public record Note(String title, String content) { }

They differ in two ways that matter in practice. Records are implicitly final and immutable — there is no var component and no subclassing. And there is no copy(): a modified record means writing out every component, which is why builder patterns reappear around records and do not around data classes.

Frequently asked questions

What does the data modifier generate?

equals, hashCode, toString, componentN() for each primary-constructor property, and copy().

Why is a property I added not in toString or equals?

Only primary-constructor properties count. A property declared in the class body is excluded from all generated members, including copy().

What is the difference between == and === in Kotlin?

== calls equals (structural equality); === compares references. For a data class, == compares components.

Is copy() a deep copy?

No. It is shallow. A MutableList inside the class is shared with the copy. Use read-only types for properties and the issue does not arise.

Does copy() run init blocks?

Yes, it calls the constructor, so require checks still apply. Copying to an invalid state throws.

Why did destructuring return the wrong values?

It is positional, based on declaration order. If the constructor was reordered, every val (a, b) = ... site now binds differently, and it still compiles when the types match.

Can a data class have no constructor parameters?

No. At least one is required, and every one must be declared val or var.

Can a data class be open or abstract?

No, not open, abstract, sealed or inner. It can implement interfaces.

Should I use a data class or a value class?

For a single wrapped property, @JvmInline value class gives type safety with no allocation. A data class earns its keep from two properties up, or when copy() and destructuring are useful.

How do data classes compare to Java records?

Records are always final and immutable and have no copy(). Kotlin data classes allow var properties and generate copy(), which is why immutable update patterns are simpler in Kotlin.

Where should I go next?

The Kotlin guides cover the rest of the language, and Java Optional covers the equivalent absence-handling problem on the Java side.