Kotlin tiene tres tipos de clases pensadas específicamente para modelar datos: data class, sealed class y value class. Cada una resuelve un problema distinto, y usarlas bien marca la diferencia entre código que se entiende a la primera y código que hay que descifrar.
data class: las clases que hacen el trabajo sucio por ti
Cuando defines una data class, el compilador genera automáticamente equals(), hashCode(), toString(), copy() y las funciones componentN() necesarias para el destructuring. Todo eso gratis, sin escribir una sola línea extra.
data class User(val id: Int, val name: String, val email: String)
Con una clase normal, comparar dos instancias por valor no funciona. User("a") == User("a") devuelve false porque la comparación es por referencia. Con data class compara los campos del constructor primario, que es lo que quieres el 99% de las veces.
copy(): inmutabilidad sin drama
Cuando necesitas modificar un campo pero mantener el resto, copy() crea una nueva instancia cambiando solo lo que le indiques:
val updated = user.copy(name = "Nuevo nombre")
No tocas el original. No necesitas constructores adicionales ni builders. Directo al grano.
Destructuring
Gracias a las funciones componentN() generadas automáticamente, puedes desempaquetar los campos de una data class de forma directa:
val (id, name, email) = user
Muy útil cuando trabajas con listas de pares o resultados de funciones que devuelven varios valores a la vez.
Qué va dentro y qué no
Solo los campos declarados en el constructor primario participan en equals() y hashCode(). Si añades propiedades en el cuerpo de la clase, el compilador las ignora para esos cálculos. Ojo con esto si tienes campos calculados o de estado interno.
Por diseño, una data class no puede ser open: no puedes heredar de ella. Si necesitas jerarquías, ahí entran las sealed class.
Cuándo usarlas: modelos de datos, DTOs, entidades, respuestas de API. Son el caballo de batalla del modelado en Kotlin.
sealed class: jerarquías cerradas para modelar estados
Una sealed class define un conjunto cerrado de subtipos. El compilador sabe exactamente qué subclases existen porque todas tienen que estar en el mismo módulo. Eso le permite al compilador verificar que un when es exhaustivo sin necesitar else.
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val msg: String) : Result<Nothing>()
}
El when sobre un Result es limpio y seguro:
when (result) {
is Result.Success -> mostrarDatos(result.data)
is Result.Error -> mostrarError(result.msg)
}
Sin else. Si añades un subtipo nuevo a la sealed class y te olvidas de tratarlo en algún when, el compilador te avisa. Eso vale mucho.
El patrón Result sin excepciones
Modelar éxito y error con una sealed class tiene una ventaja sobre las excepciones: el caller no puede ignorar el caso de error. La firma de la función devuelve un Result<T> y el compilador fuerza a tratarlo en algún momento.
La stdlib de Kotlin tiene su propio kotlin.Result<T> y puede servir para casos genéricos, pero un sealed propio es más expresivo cuando el dominio tiene varios estados distintos, no solo éxito/error.
sealed interface: más flexible que sealed class
Desde Kotlin 1.5 puedes usar sealed interface en lugar de sealed class. La diferencia práctica: las subclases pueden heredar de otra clase y además implementar el sealed interface, algo que no es posible con sealed class porque Kotlin no admite herencia múltiple de clases.
sealed interface UiState
data class Loading(val progress: Int) : UiState
data class Ready(val content: String) : UiState
object Empty : UiState
En proyectos modernos el sealed interface se ha convertido en la opción habitual porque da más flexibilidad sin perder la exhaustividad del when.
value class: envolver un tipo sin overhead
Imagina que tienes un Int que representa un ID de usuario y otro que representa un ID de pedido. Desde el punto de vista del compilador son el mismo tipo y puedes mezclarlos sin que salte ningún error. Una value class resuelve eso:
@JvmInline
value class UserId(val value: Int)
@JvmInline
value class OrderId(val value: Int)
Ahora UserId(42) y OrderId(42) son tipos distintos. Si una función espera un UserId, pasarle un OrderId es un error de compilación. En runtime, la clase desaparece y queda solo el Int interno: sin boxing, sin overhead de memoria.
Métodos y propiedades calculadas
Una value class puede tener métodos propios y propiedades calculadas, lo que permite añadir lógica de dominio directamente:
@JvmInline
value class Email(val value: String) {
val domain: String get() = value.substringAfter('@')
fun isValid(): Boolean = value.contains('@') && value.contains('.')
}
Restricción actual: solo puede haber un campo en el constructor primario. Si necesitas varios campos, tendrás que usar una data class normal.
value class con interfaces
Puedes implementar interfaces en una value class. Útil cuando el tipo envuelto necesita participar en contratos existentes:
@JvmInline
value class Email(val value: String) : Serializable, Comparable<Email> {
override fun compareTo(other: Email) = value.compareTo(other.value)
}
Combinando los tres: modelar un dominio rico
La combinación de los tres tipos es donde se ve el potencial real. Aquí tienes un ejemplo de un dominio de pedidos:
@JvmInline
value class OrderId(val value: UUID)
sealed class OrderStatus {
object Pending : OrderStatus()
data class Shipped(val trackingId: String) : OrderStatus()
data class Cancelled(val reason: String) : OrderStatus()
}
data class Order(
val id: OrderId,
val items: List<OrderItem>,
val status: OrderStatus
)
El when sobre order.status es exhaustivo, los IDs son tipos distintos y la estructura del pedido es inmutable. Cuando alguien lee este código, entiende qué estados existen sin necesidad de ir a buscar documentación externa.
when (order.status) {
is OrderStatus.Pending -> mostrarPendiente()
is OrderStatus.Shipped -> mostrarTracking(order.status.trackingId)
is OrderStatus.Cancelled -> mostrarCancelacion(order.status.reason)
}
Si el equipo decide añadir un estado Delivered, el compilador marca todos los when que no lo tratan. No hace falta buscarlos a mano.
Resumen: cuándo usar cada uno
- data class: cuando necesitas un contenedor de datos con igualdad por valor,
copy()y destructuring. DTOs, entidades, respuestas de API. - sealed class / sealed interface: cuando tienes un conjunto finito de estados o variantes y quieres que el compilador verifique que los tratas todos. Estados de UI, resultados de operaciones, eventos de dominio.
- value class: cuando quieres añadir semántica de tipo a un primitivo sin pagar el coste de un objeto en runtime. IDs, unidades de medida, valores con formato.
Más sobre el sistema de tipos de Kotlin en Kotlin: clases, tipos y el sistema de modelado de datos. Y si vienes de Java, en Kotlin vs Java: menos código, más expresividad tienes una comparativa directa de cuánto código te ahorras.
Imagen: Pexels / Jakub Zerdzicki
