Data Class and Copy in Kotlin

1. Overview

data class is one of the features that helps Kotlin write much more concise code than Java when working with classes that only contain data (DTOs, models, value objects). This article helps you understand what Kotlin generates for a data class, how to use copy() to create a copy with some modified fields, and common pitfalls like incorrect comparison, using var in data class, or inheriting a data class.

2. What Is a Data Class?

When you declare a class with the data keyword, the Kotlin compiler automatically generates several functions based on the properties declared in the primary constructor.

data class Customer(
    val id: Long,
    val name: String,
    val email: String
)

With just these 4 lines, Kotlin generates:

  • equals() / hashCode() based on id, name, email.
  • toString() in the form Customer(id=1, name=..., email=...).
  • copy() to create a copy that can modify some fields.
  • componentN() to support destructuring declaration.

Compared to Java, you’d have to write a class with constructor, getters, equals, hashCode, toString — around 40-50 lines for the same functionality.

2.1. Default toString()

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)

The auto-generated toString() is very convenient for logging or debugging, you don’t need to write it yourself.

3. equals() and hashCode() By Value

The biggest difference between data class and a regular class is that equals() compares by value (structural equality) rather than by reference.

fun main() {
    val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
    val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

    println(c1 == c2)   // compare by value
    println(c1 === c2)  // compare by reference
}
true
false
  • == calls equals(), two objects with the same fields are equal even if they have different references.
  • === checks if two variables point to the same object in memory.

Note: With a regular class (not a data class), == by default compares by reference like ===, unless you override equals() yourself.

Because hashCode() is also generated based on the properties, you can safely use data class as a key in HashMap or an element in HashSet.

fun main() {
    val set = hashSetOf(
        Customer(1, "An Nguyen", "an@nonladev.com"),
        Customer(1, "An Nguyen", "an@nonladev.com")
    )
    println(set.size)
}
1

Two objects with identical values are considered the same, HashSet only keeps 1 element.

4. copy() Function — Creating Immutable Copies

If you use val for properties, Customer is immutable — you can’t modify fields directly after creation. To “change” some values, Kotlin generates a copy() function that lets you create a new object while keeping unchanged fields.

fun main() {
    val original = Customer(1, "An Nguyen", "an@nonladev.com")
    val updated = original.copy(email = "an.new@nonladev.com")

    println(original)
    println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
  • original is never modified — this is the essence of immutability.
  • updated is a new object, differing only in email, other fields are copied as-is.

This is a very common pattern when working with state in UI (Compose, MVI) or when updating entities in business logic without directly mutating the object.

4.1. copy() With Named Arguments

You can pass multiple parameters at once, by name (named arguments) to avoid confusion with order:

val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")

If you don’t use named arguments and go by position order, the code can easily become confused when the class has many fields of the same type like String.

5. Shallow Copy — Pitfall With Mutable Objects Inside

copy() only creates a shallow copy: it copies references to the fields, not deep copies of complex objects inside.

data class Order(
    val id: Long,
    val items: MutableList<String>
)

fun main() {
    val order1 = Order(1, mutableListOf("Book"))
    val order2 = order1.copy()

    order2.items.add("Pen")

    println(order1.items) // affected!
    println(order2.items)
}
[Book, Pen]
[Book, Pen]
  • order2 = order1.copy() only creates a new Order object, but the items field still points to the same MutableList.
  • Modifying order2.items causes order1.items to change too — this is a very common mistake when using mutable collections in data class.

Solution: use immutable List (toList()) or manually copy the collection in the constructor.

data class Order(
    val id: Long,
    val items: List<String>
)

fun main() {
    val order1 = Order(1, listOf("Book"))
    val order2 = order1.copy(items = order1.items + "Pen")

    println(order1.items)
    println(order2.items)
}
[Book]
[Book, Pen]

Use immutable List<String> and the + operator to create a new list instead of mutating, staying true to the spirit of immutability in data class.

Note: If you truly need deep copy (nested data class containing another data class), you have to write recursive copy logic yourself, Kotlin doesn’t generate deep copy automatically.

6. Destructuring Declaration

Thanks to the auto-generated componentN(), you can “destructure” a data class into multiple separate variables:

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    val (id, name, email) = customer

    println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com

The order of variables corresponds to the order of properties in the primary constructor, not by name. Changing the order of property declarations will cause destructuring to be misaligned — be careful when refactoring.

Note: Only destructure with data class having few fields (2-3). With many fields, use property names directly for more readable code.

7. Limitations of Data Class

7.1. Cannot Inherit

A data class cannot inherit from another data class (cannot open with data in a way that automatically inherits fields):

// Compilation error if you try this:
// data class VipCustomer(val tier: String) : Customer(...)

Kotlin only allows data class to implement interfaces, or inherit from a regular class (non-data) with no properties in the primary constructor. Reason: the auto-generated equals()/copy() would be very hard to define correctly with multi-level field inheritance.

Common solution: composition instead of inheritance.

data class VipCustomer(
    val customer: Customer,
    val tier: String
)

7.2. Primary Constructor Must Have At Least 1 Property

// Error: data class must have at least 1 parameter in primary constructor
// data class Empty

7.3. Only Properties in Primary Constructor Are Considered

data class Product(val name: String) {
    var discount: Int = 0
}

fun main() {
    val p1 = Product("Laptop").apply { discount = 10 }
    val p2 = Product("Laptop").apply { discount = 20 }

    println(p1 == p2) // true, discount not included in equals()
}
true

discount is declared in the body, not in the primary constructor, so it’s not included in equals(), hashCode(), toString(), copy(). This is an easy-to-miss mistake — if a field is important for comparison, it must go in the primary constructor.

8. Testing With JUnit 5 and AssertJ

Required dependency (Gradle Kotlin DSL):

testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")

Write tests to check the behavior of copy() and equals():

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `copy should keep unchanged fields and update specified ones`() {
        val original = Customer(1, "An Nguyen", "an@nonladev.com")

        val updated = original.copy(email = "an.new@nonladev.com")

        assertThat(updated.id).isEqualTo(original.id)
        assertThat(updated.name).isEqualTo(original.name)
        assertThat(updated.email).isEqualTo("an.new@nonladev.com")
        assertThat(original.email).isEqualTo("an@nonladev.com")
    }

    @Test
    fun `two customers with same fields should be equal`() {
        val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
        val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

        assertThat(c1).isEqualTo(c2)
        assertThat(c1).isNotSameAs(c2)
    }
}
  • isEqualTo checks equality by value, consistent with the nature of equals() in data class.
  • isNotSameAs confirms that c1 and c2 are two different objects in memory, despite being equal by value.
  • The first test confirms copy() works correctly: unspecified fields remain the same, specified field is updated, original object is unchanged.

9. When Not to Use Data Class

  • JPA/Hibernate entities: equals()/hashCode() on all fields (including auto-generated fields like id before persistence) easily cause errors when used in HashSet or comparing persisted/unpersisted entities. Write equals()/hashCode() manually based on id for entities.
  • Classes with complex business logic: data class is for data holders, not suitable when a class needs many behaviors, multi-level inheritance.
  • Classes needing memory optimization with many nested mutable fields: consider using builder pattern or regular class if shallow copy poses hard-to-control risks.

10. Conclusion

data class helps you eliminate boilerplate code when working with DTOs/models in Kotlin: auto-generates equals(), hashCode(), toString(), copy() and supports destructuring. The copy() function is a powerful tool for maintaining immutability, but remember it only does shallow copy — be careful with fields containing mutable collections or objects inside. Avoid using data class for JPA entities or classes with complex business logic, and always put important fields in the primary constructor so equals()/copy() work as expected.

kotlindata-classcopyimmutabilitykotlin-basics