Data classes: equality and copying for free
Most classes in a real program are data holders — a Customer, an Order, a Point — whose job
is to carry a few values around. For these, Kotlin has the data class, which generates a pile of
useful behaviour from a single keyword. It is one of the features that makes Kotlin so much less
verbose than Java, and understanding exactly what it gives you (and what to watch for) is worth a
lesson.
The keyword
Add data before class:
data class Customer(val name: String, val email: String, val city: String)
That one word tells the compiler to generate five things automatically, each of which you would otherwise write (and maintain) by hand. Let us see them.
What you get for free
1. A readable toString()
val c = Customer("Kavita", "kavita@example.com", "Pune")
println(c) // Customer(name=Kavita, email=kavita@example.com, city=Pune)
An ordinary class prints something useless like Customer@1b6d3586 (the class name and a memory
hash). A data class prints its contents — invaluable for debugging and logging. This alone is worth
the keyword.
2. Value equality — equals() and hashCode()
This is the big one, and it fixes a classic Java pitfall. Two data-class instances with the same contents are equal:
val a = Customer("Kavita", "kavita@example.com", "Pune")
val b = Customer("Kavita", "kavita@example.com", "Pune")
println(a == b) // true — same contents, so equal
println(a === b) // false — but different objects in memory
With an ordinary class, a == b would be false — the default equals compares identity (are
they the same object?), not contents. A data class overrides equals to compare the properties, so
== does what you almost always mean: two customers with identical data are the same customer. And
it keeps hashCode() consistent with equals (equal objects have equal hash codes), which is what
lets data classes work correctly as keys in a map or elements of a set — a subtlety that causes
real bugs when done wrong in Java, and that you get right for free here.
3. copy() — a new object with some fields changed
Because you favour val (immutable) properties, you often want "the same object but with one field
different". copy() does exactly that:
val kavita = Customer("Kavita", "kavita@example.com", "Pune")
val moved = kavita.copy(city = "Mumbai")
println(moved) // Customer(name=Kavita, email=kavita@example.com, city=Mumbai)
println(kavita) // Customer(name=Kavita, email=kavita@example.com, city=Pune) — unchanged
copy(city = "Mumbai") makes a new Customer identical to kavita except for city. The original
is untouched. This is how you "change" an immutable object — you do not mutate it, you make a modified
copy. It pairs perfectly with val properties and named arguments, and it is the idiomatic way to
update immutable data in Kotlin.
4. Destructuring — componentN()
A data class can be unpacked into its parts, the destructuring you met with maps and withIndex:
val (name, email, city) = kavita
println("$name lives in $city") // Kavita lives in Pune
The compiler generated component1(), component2(), component3() for the three properties, which
is what makes val (a, b, c) = ... work. Handy, though use it where the field order is obvious — for
many fields, accessing by name is clearer.
When to use a data class — and when not
Use data class for anything whose identity is its contents — a DTO, a record, a value object,
an API response, a coordinate. If two instances with the same fields should be considered the same
thing, it is a data class.
Do not use data class for:
- Objects with identity beyond their data — a
BankAccountis not "equal" to another account just because they hold the same balance; each account is a distinct thing. Use an ordinary class. - Classes that are mostly behaviour — a
PaymentProcessorwith methods and no meaningful "value" is not a data class. - Classes in an inheritance hierarchy as the base — data classes are designed to be final-ish value types, not the root of a hierarchy.
The pitfalls worth knowing
Two things that surprise people, both flowing from how the generated code works:
Only primary-constructor properties count. equals, hashCode, toString and copy use only
the properties declared in the primary constructor. A property declared in the class body is not
included:
data class User(val id: Int) {
var lastSeen: Long = 0 // NOT part of equals/hashCode/toString/copy
}
val u1 = User(1).apply { lastSeen = 100 }
val u2 = User(1).apply { lastSeen = 999 }
println(u1 == u2) // true! — lastSeen is ignored, only id counts
This is usually what you want (identity is the id), but it can surprise you. Put the properties
that define equality in the primary constructor.
copy is a shallow copy. copy() copies the references, not the objects they point to. If a
data class holds a MutableList, the copy shares the same list — change it through one and the
other sees it. This is the read-only-view caveat from the collections module, again. The fix is the
same: keep data-class properties immutable (use List, not MutableList), and then shallow copying
is safe because nothing can be mutated.
Check your work
What the data keyword generates. toString, equals, hashCode, copy, and componentN
destructuring.
What a data class's toString shows. Its property values, not a memory hash.
How == behaves on data classes. Value equality — equal contents means equal; === still checks
identity.
Why consistent hashCode matters. It lets data classes work correctly as map keys and set
elements.
What copy() does. Makes a new instance with some fields changed, leaving the original untouched
— the idiomatic way to "update" immutable data.
When to use a data class. When an object's identity is its contents — DTOs, records, value objects.
When not to. Objects with identity beyond their data, behaviour-heavy classes, or the base of an inheritance hierarchy.
The primary-constructor pitfall. Only primary-constructor properties are used by the generated
methods; body properties are ignored by equals/copy.
Why copy can share data. It is shallow — keep properties immutable so this is safe.
Practice
- Write
data class Customer(...)with three properties, create one, and print it. Note the readable output. - Create two customers with identical data and compare them with
==(true) and===(false). - Create the same as an ordinary
class(dropdata) and confirm==is nowfalse. Explain why. - Use
copy(city = ...)to make a moved customer and confirm the original is unchanged. - Destructure a customer into
(name, email, city)and use the parts. - Add a body property (
var lastSeen), set it differently on two otherwise-equal instances, and confirm they are still==. Explain. - Make a data class holding a
MutableList,copy()it, mutate the list through one copy, and watch the other change. Then switch toListand reason about why the problem disappears. - Decide, for three types in an app you know, which should be data classes and which ordinary classes.
Official documentation
- Kotlin — Data classes — What is generated, and the primary-constructor rule.
- Kotlin — Destructuring declarations — How
componentNpowersval (a, b) = .... - Kotlin — Equality —
equals/hashCodeand==versus===.
Next: interfaces and inheritance — sharing behaviour between classes.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship