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 enid,name,email.toString()con formatoCustomer(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
==invocaequals(), 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 sobrescribasequals()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)
originalnunca se modifica — esta es la esencia de la inmutabilidad.updatedes un nuevo objeto, solo diferente enemail, 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 objetoOrder, pero el campoitemssigue apuntando a la mismaMutableList.- Modificar
order2.itemshace queorder1.itemscambie 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)
}
}
isEqualToverifica igualdad por valor, correcto con la naturaleza deequals()dedata class.isNotSameAsconfirma quec1yc2son 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 comoidantes de persistir) fácilmente causa errores cuando se usan enHashSeto se comparan entidades ya/no guardadas en BD. Es mejor escribirequals()/hashCode()manualmente basado enidpara entidades. - Clases con lógica empresarial compleja:
data classestá 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.