Data Class y Copy en Kotlin

1. Descripción general

data class es una de las características que permiten a Kotlin escribir código mucho más conciso que Java cuando trabajas con clases que solo contienen datos (DTO, model, value object). Este artículo te ayuda a entender qué genera Kotlin para una data class, cómo usar copy() para crear una copia con algunas modificaciones de campos, y las trampas comunes como comparaciones incorrectas, usar var en una data class, o heredar de data class.

2. ¿Qué es una Data Class?

Cuando declaras una clase con la palabra clave data, el compilador de Kotlin genera automáticamente varias funciones basadas en las propiedades declaradas en el constructor principal (primary constructor).

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

Con solo estas 4 líneas, Kotlin genera automáticamente:

  • equals() / hashCode() basado en id, name, email.
  • toString() con formato Customer(id=1, name=..., email=...).
  • copy() para crear una copia que puede modificar algunos campos.
  • componentN() para soportar declaraciones de destructuring.

En comparación con Java, tendrías que escribir una clase con constructor, getters, equals, hashCode, toString — alrededor de 40-50 líneas para la misma funcionalidad.

2.1. toString() por Defecto

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

toString() se genera automáticamente, lo que es muy práctico al hacer logs o debug, no necesitas escribirlo tú mismo.

3. equals() y hashCode() por Valor

La diferencia más importante entre data class y una clase normal es que equals() compara por valor (structural equality) y no por referencia.

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

    println(c1 == c2)   // comparación por valor
    println(c1 === c2)  // comparación por referencia
}
true
false
  • == invoca equals(), dos objetos con los mismos campos son iguales aunque sean referencias diferentes.
  • === verifica si dos variables apuntan al mismo objeto en memoria.

Nota: Con una clase normal (que no sea data class), == por defecto compara referencias igual que ===, a menos que sobrescribas equals() manualmente.

Dado que hashCode() también se genera basado en las propiedades, puedes usar data class como clave en un HashMap o elemento en un HashSet de manera segura.

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

Dos objetos con valores idénticos se consideran iguales, HashSet solo mantiene 1 elemento.

4. Función copy() — Crear Copias Inmutables

Si usas val para las propiedades, Customer es inmutable — no puedes modificar campos directamente después de crear la instancia. Para “cambiar” algunos valores, Kotlin genera la función copy() que permite crear un nuevo objeto, manteniendo los campos no especificados.

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 nunca se modifica — esta es la esencia de la inmutabilidad.
  • updated es un nuevo objeto, solo diferente en email, los demás campos se copian tal cual.

Este patrón es muy común al trabajar con estado en UI (Compose, MVI) o cuando actualizas entidades en la lógica empresarial sin querer mutar directamente.

4.1. copy() con Named Arguments

Puedes pasar múltiples parámetros a la vez, usando nombres (named arguments) para evitar confusiones con el orden:

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

Si no usas named arguments sino el orden de posición, el código puede ser confuso cuando la clase tiene muchos campos del mismo tipo String.

5. Shallow Copy — Trampa Cuando Hay Objetos Mutables Adentro

copy() solo crea una shallow copy: copia las referencias de los campos, no hace una copia profunda (deep copy) de los objetos complejos adentro.

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) // ¡fue afectado!
    println(order2.items)
}
[Book, Pen]
[Book, Pen]
  • order2 = order1.copy() solo crea un nuevo objeto Order, pero el campo items sigue apuntando a la misma MutableList.
  • Modificar order2.items hace que order1.items cambie también — este es un error muy fácil de cometer al usar colecciones mutables en una data class.

Solución: usa List inmutable (toList()) o copia manualmente la colección en el 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]

Usa List<String> inmutable y el operador + para crear una nueva lista en lugar de mutarla, manteniendo el espíritu de inmutabilidad de data class.

Nota: Si necesitas una deep copy real (data class anidada que contiene otra data class), tienes que escribir tu propia lógica de copia recursiva, Kotlin no genera deep copy automáticamente.

6. Declaraciones de Destructuring

Gracias a componentN() generado automáticamente, puedes “destructurar” una data class en varias variables individuales:

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

El orden de las variables corresponde al orden de las propiedades en el constructor principal, no al nombre. Cambiar el orden de declaración de las propiedades haría que el destructuring sea incorrecto — sé cuidadoso cuando refactorices.

Nota: Solo destructuriza con data class que tengan pocos campos (2-3). Con clases que tengan muchos campos, usa los nombres de propiedad directamente para que el código sea más legible.

7. Limitaciones de Data Class

7.1. No Puedes Heredar

Una data class no puede heredar de otra data class (no puedes usar open junto con data de manera que herede campos automáticamente):

// Error de compilación si intentas esto:
// data class VipCustomer(val tier: String) : Customer(...)

Kotlin solo permite que data class implemente interfaces, o herede de una clase normal (non-data) que no tenga propiedades en el constructor principal. La razón: equals()/copy() generados automáticamente serían muy difíciles de definir correctamente con herencia multi-nivel de campos.

Solución común: composición en lugar de herencia.

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

7.2. El Constructor Principal Debe Tener al Menos 1 Propiedad

// Error: una data class debe tener al menos 1 parámetro en el constructor principal
// data class Empty

7.3. Solo Las Propiedades del Constructor Principal Se Cuentan

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 no participa en equals()
}
true

discount se declara en el cuerpo, no está en el constructor principal, así que no se cuenta en equals(), hashCode(), toString(), copy(). Este es un error fácil de cometer — si un campo es importante para la comparación, debe estar en el constructor principal.

8. Pruebas con JUnit 5 y AssertJ

Dependencias necesarias (Gradle Kotlin DSL):

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

Escribe pruebas para verificar el comportamiento de copy() y 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 verifica igualdad por valor, correcto con la naturaleza de equals() de data class.
  • isNotSameAs confirma que c1 y c2 son dos objetos diferentes en memoria, aunque sean iguales en valor.
  • La primera prueba confirma que copy() funciona correctamente: campos no especificados se mantienen igual, campos especificados se actualizan, el objeto original no cambia.

9. Cuándo No Usar Data Class

  • Entidades JPA/Hibernate: equals()/hashCode() basado en todos los campos (incluyendo campos generados como id antes de persistir) fácilmente causa errores cuando se usan en HashSet o se comparan entidades ya/no guardadas en BD. Es mejor escribir equals()/hashCode() manualmente basado en id para entidades.
  • Clases con lógica empresarial compleja: data class está hecha para contenedores de datos, no es apropiada cuando la clase necesita muchas conductas y herencia multi-nivel.
  • Clases que necesitan optimizar memoria con muchos campos mutable anidados: considera usar el patrón builder o una clase normal si shallow copy causa riesgos difíciles de controlar.

10. Conclusión

data class te ayuda a eliminar código boilerplate al trabajar con DTO/models en Kotlin: genera automáticamente equals(), hashCode(), toString(), copy() y soporta destructuring. La función copy() es una herramienta poderosa para mantener la inmutabilidad, pero debes recordar que solo hace shallow copy — ten cuidado con campos que contienen colecciones u objetos mutables adentro. Evita usar data class para entidades JPA o clases con lógica empresarial compleja, y siempre pon los campos importantes en el constructor principal para que equals()/copy() funcionen como esperas.

kotlindata-classcopyimmutabilitykotlin-basics