Data Classes
A data class is Kotlin’s built-in way to model a value: a small holder of related properties, like a point, a user record, or the result of an API call. Adding the data modifier to a class tells the compiler to generate the boilerplate every value type needs: a structural equals() and hashCode(), a readable toString(), a copy() function for making modified copies, and componentN() functions for destructuring. In Java, writing all of that by hand, or trusting an IDE to keep generated methods in sync as fields change, is a well-known source of bugs. In Kotlin it collapses into a single line that the compiler keeps correct for you automatically.
Overview: How Data Classes Work
Marking a class with data tells the compiler to synthesize several methods based on the properties declared in the primary constructor. There are a few rules: the primary constructor must have at least one parameter, every parameter must be marked val or var, and a data class cannot be abstract, open, sealed, or inner (though a sealed class’s individual subtypes can themselves be data classes, which is a very common pattern). Only properties listed in the primary constructor participate in the generated members. A property declared inside the class body, after the constructor, is invisible to equals(), hashCode(), toString(), copy(), and destructuring – the compiler simply does not know it exists for those purposes.
The generated equals() compares two instances by type and then by every primary-constructor property, so two separately-constructed objects with the same property values are considered equal. This is structural equality, and it’s what the == operator calls under the hood for any Kotlin type. Contrast this with an ordinary class, which inherits equals() from Any and only considers two instances equal if they are literally the same object in memory. The === operator always checks that kind of reference identity, regardless of whether the class is a data class or not – == and === are never interchangeable, and a data class is exactly the case where they most visibly disagree. The generated hashCode() combines the hash codes of the same properties (conceptually result = 31 * result + property.hashCode() for each one), which is why two equal data class instances always produce the same hash code, a requirement for correct behavior in HashSet and HashMap.
toString() prints the class name followed by each property and its value, in declaration order, which makes debugging and logging output immediately readable without writing a custom toString(). copy() creates a new instance with the same property values as the original, except for whichever named arguments you override; it does not mutate anything, which pairs naturally with val properties to give you an immutable "update" workflow instead of mutation. Finally, the compiler generates component1(), component2(), and so on, one per primary-constructor property in declaration order, which is what powers destructuring declarations like val (x, y) = point. If you write your own equals(), hashCode(), or toString() inside the class body, the compiler uses your version instead of generating one, which is occasionally useful when you want to exclude a property from equality.
Syntax
data class ClassName(val property1: Type1, val property2: Type2 = defaultValue)
data– the modifier that triggers code generation for this class.ClassName– any valid class identifier, by convention PascalCase.val/var– every primary constructor parameter must be one or the other;valis strongly preferred.Type1,Type2– can be any type, including nullable types likeString?.= defaultValue– optional default value, exactly like any other Kotlin function parameter.
| Generated member | Based on | Purpose |
|---|---|---|
equals() / hashCode() |
primary constructor properties | structural comparison used by == and hash-based collections |
toString() |
primary constructor properties | readable ClassName(prop1=value1, prop2=value2) output |
copy() |
primary constructor properties | create a modified instance without mutating the original |
componentN() |
primary constructor properties, in order | enables destructuring declarations |
Examples
Example 1: Equality, identity, and copy
data class Point(val x: Int, val y: Int)
fun main() {
val p1 = Point(1, 2)
val p2 = Point(1, 2)
val p3 = p1.copy(y = 5)
println(p1)
println(p1 == p2)
println(p1 === p2)
println(p3)
}
Output:
Point(x=1, y=2)
true
false
Point(x=1, y=5)
p1 and p2 are two distinct objects with identical property values, so p1 == p2 is true (structural equality) while p1 === p2 is false (they are different objects in memory). p1.copy(y = 5) builds a brand-new Point with x carried over from p1 and y overridden to 5; p1 itself is untouched, which you’d expect anyway since its properties are val.
Example 2: Nullable properties and destructuring
data class User(val name: String, val email: String?, val age: Int)
fun greet(user: User): String {
val emailText = user.email?.let { "reachable at $it" } ?: "no email on file"
return "${user.name} (${user.age}) is $emailText"
}
fun main() {
val alice = User("Alice", "alice@example.com", 30)
val bob = User("Bob", null, 25)
println(greet(alice))
println(greet(bob))
val (name, email, age) = alice
println("Destructured: $name, $email, $age")
}
Output:
Alice (30) is reachable at alice@example.com
Bob (25) is no email on file
Destructured: Alice, alice@example.com, 30
email is typed String?, so the compiler forces you to handle the null case; ?.let { ... } only runs when email is non-null, and ?: supplies a fallback when it’s null. There is no way to accidentally dereference a null email here – the code simply would not compile. The last few lines show destructuring: val (name, email, age) = alice calls component1(), component2(), and component3() in that order, which matches the declaration order of User‘s primary constructor.
Example 3: Data classes in collections
data class Product(val id: Int, val name: String, val price: Double)
fun main() {
val products = listOf(
Product(1, "Keyboard", 49.99),
Product(2, "Mouse", 19.99),
Product(1, "Keyboard", 49.99)
)
val uniqueProducts = products.toSet()
println("Total entries: ${products.size}")
println("Unique products: ${uniqueProducts.size}")
val discounted = products[0].copy(price = 39.99)
println(discounted)
}
Output:
Total entries: 3
Unique products: 2
Product(id=1, name=Keyboard, price=39.99)
The list has two entries that describe the exact same keyboard. Calling toSet() uses the generated equals()/hashCode() to deduplicate, collapsing the two identical Product(1, "Keyboard", 49.99) entries into one, so the set has 2 elements instead of 3. None of this would work with plain classes unless you wrote equals()/hashCode() yourself. copy(price = 39.99) then produces a discounted product without mutating anything in the original list.
How It Works Step by Step
When the compiler processes a data class declaration, it walks through roughly this sequence at compile time: it reads the primary constructor and collects every parameter marked val or var; it generates an equals(other: Any?) that first checks the runtime type with is and then compares each collected property; it generates a hashCode() that folds the hash code of each property together; it generates a toString() that prints the class name and each property; it generates a copy() whose parameters mirror the primary constructor, each defaulting to this.property; and finally it generates one componentN() function per property, numbered in declaration order. None of this touches the JVM bytecode differently from a hand-written class – it’s ordinary methods, just written for you.
Tracing Example 1: Point(1, 2) allocates an object on the heap for p1, and a second, separate allocation for p2 with the same field values. When p1 == p2 runs, Kotlin calls p1.equals(p2), which compares x and y field by field and returns true. p1 === p2 compares the two heap addresses directly and returns false, since they’re different objects. p1.copy(y = 5) calls the generated copy(x: Int = this.x, y: Int = this.y), passing x through unchanged and y as 5, producing a third, independent object.
Common Mistakes
Mistake 1: Using a plain class and expecting value equality
class PointWrong(val x: Int, val y: Int)
fun main() {
val a = PointWrong(1, 2)
val b = PointWrong(1, 2)
println(a == b)
}
Output:
false
This compiles fine, which is exactly what makes it dangerous. Without the data modifier, PointWrong inherits equals() from Any, which only checks reference identity, so a == b is false even though every property matches. The fix is simply to add data to the declaration, exactly as Point is defined in Example 1 above, which gives you the structural comparison for free.
Mistake 2: Mutable properties as hash keys
data class MutablePoint(var x: Int, var y: Int)
fun main() {
val points = hashSetOf(MutablePoint(1, 2))
val p = points.first()
p.x = 99
println(points.contains(p))
}
Output:
false
A HashSet places an element into a bucket based on its hashCode() at insertion time. Because MutablePoint uses var, mutating p.x after insertion changes what hashCode() now returns, but the set does not rehash the element into a new bucket. contains(p) looks in the bucket for the new hash code, doesn’t find the object sitting in the old bucket, and reports false – even though p is, by reference, still literally in the set. The fix is to keep data class properties val and produce a new value instead of mutating an existing one:
data class ImmutablePoint(val x: Int, val y: Int)
fun main() {
val points = hashSetOf(ImmutablePoint(1, 2))
val original = points.first()
val moved = original.copy(x = 99)
println(points.contains(original))
println(points.contains(moved))
}
Output:
true
false
Now the set’s contents can never silently drift out of sync with their own hash codes. original is still found correctly, and moved is correctly reported as a value that was never added – both answers are trustworthy.
Mistake 3: Assuming destructuring is safe across refactors
// Before: a two-property Event, destructured positionally
data class Event(val type: String, val id: Int)
val (type, id) = someEvent
// After inserting `priority` into the middle of the constructor...
data class Event(val type: String, val priority: Int, val id: Int)
val (type, id) = someEvent // id now silently receives the old value of priority!
Destructuring is purely positional – component2() always returns the second primary-constructor property, whatever it happens to be named later. If a teammate inserts a new property in the middle of the constructor, every existing destructuring call site keeps compiling but starts binding the wrong values to the wrong names, with no compiler error at all. This is especially dangerous because it fails silently. Prefer named property access (event.id) over destructuring for data classes with more than two or three properties, or for any data class that’s part of a public API likely to grow over time.
Best Practices
- Reach for a data class whenever a type’s entire identity is its values – points, coordinates, DTOs, query results, configuration records.
- Prefer
valovervarfor data class properties; it gives you true immutability and makes the class safe to use as aHashMapkey or inside aHashSet. - Use
copy()with named arguments to produce a changed value instead of mutating fields in place. - Put properties that shouldn’t affect equality, hashing, or
toString()in the class body, after the primary constructor, not inside it. - Don’t override
equals()/hashCode()/toString()yourself unless you specifically need to exclude a property from comparison – let the compiler do it. - Combine data classes with
sealed classhierarchies and an exhaustivewhenexpression to model a fixed set of variant values precisely. - Avoid destructuring data classes with many properties in code that might be refactored later; named access is more resilient to constructor changes.
Practice Exercises
- Define
data class Book(val title: String, val author: String, val pages: Int). Create twoBookinstances with identical values via separate constructor calls, and print whether they are equal with==and whether they are the same object with===. Expecttruethenfalse. - Using the same
Bookclass, create one instance and usecopy()to produce a second book with the same title and author but a different page count. Print both books. - Write
data class Temperature(val celsius: Double)with a function that destructures a list of threeTemperaturevalues and prints the average usingcomponent1()style destructuring in a loop, or via property access. Compare which approach reads more clearly.
Summary
- The
datamodifier generatesequals(),hashCode(),toString(),copy(), andcomponentN()from the primary constructor’s properties. ==calls the generated structuralequals();===always checks reference identity, regardless of the class.- Only primary-constructor properties participate in the generated members; body-declared properties are ignored.
copy()creates a new, independent instance and never mutates the original.varproperties in a data class are legal but risky as hash-based collection keys, since mutation doesn’t trigger rehashing.- Destructuring is positional, so inserting a new constructor parameter can silently break existing destructuring call sites.
- A primary constructor with at least one
val/varparameter is required; data classes cannot beabstract,open,sealed, orinner.
