Sealed class y when en Kotlin
1. Introducción
Cuando un valor solo puede caer en unos pocos casos fijos, por ejemplo el resultado de un pago es exitoso, fallido u pendiente, quieres que el compilador conozca claramente esa lista. Kotlin proporciona sealed class y sealed interface para bloquear la jerarquía de herencia. La expresión when puede verificar si has manejado todas las ramas.
Después de leer este artículo, sabrás cómo declarar una jerarquía sealed y escribir un when exhaustivo. También sabrás cómo usar guard conditions, escribir pruebas para esta lógica y reconocer cuándo no deberías usar sealed class.
2. El problema con la herencia abierta
Intenta modelar el resultado de un pago usando una abstract class común:
abstract class PaymentResult
class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()
fun describe(result: PaymentResult): String = when (result) {
is Success -> "OK: ${result.transactionId}"
is Failed -> "Error: ${result.reason}"
else -> "Desconocido"
}
La rama else es obligatoria. La razón es que cualquiera, en cualquier módulo, puede crear subclases adicionales de PaymentResult. El compilador no puede saber si la lista de subclases es completa.
La consecuencia es que cuando alguien agrega class Refunded : PaymentResult(), el código sigue compilando normalmente. El valor Refunded silenciosamente cae en la rama "Desconocido". Este tipo de error usualmente solo se revela en producción.
enum class resuelve parte del problema porque la lista de constantes es fija. Sin embargo, cada constante enum es una única instancia. No puedes adjuntar transactionId diferente para cada pago exitoso.
3. Declarando sealed class
Sealed class combina lo mejor de ambos enfoques. La lista de subclases es fija como en enum, y cada subclase aún puede llevar sus propios datos como una clase normal.
3.1. Dependencias
El ejemplo en este artículo usa Kotlin 2.2 sobre JVM 21. Las pruebas usan JUnit 5 y AssertJ:
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.20</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
También necesitas configurar kotlin-maven-plugin con la misma versión para compilar el código fuente de Kotlin.
3.2. Una jerarquía sealed simple
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
}
Cada parte en el código anterior tiene significado:
sealed class PaymentResultes una clase abstracta implícita. No puedes llamarPaymentResult()directamente. Su constructor por defecto esprotected.SuccessyFailedsondata class. Obtienes automáticamenteequals,hashCode,toStringycopy, muy útil al escribir pruebas.Pendingno lleva datos, así que usamosdata object. Es un singleton y tienetoString()que imprimePendingen lugar de algo comoPaymentResult$Pending@1b6d3586.
La regla más importante: todas las subclases directas deben estar en el mismo paquete y módulo que la sealed class. Puedes separarlas en múltiples archivos dentro del mismo paquete. Sin embargo, otro módulo que use tu biblioteca no podrá agregar nuevas subclases. Gracias a esta regla el compilador sabe con certeza la lista de subclases.
4. La expresión when exhaustiva
4.1. Omitiendo la rama else
Cuando el objeto inspeccionado en when tiene tipo sealed, no necesitas else:
fun describe(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Pagado ${result.amount} VND (código ${result.transactionId})"
is PaymentResult.Failed -> "Falló: ${result.reason}"
PaymentResult.Pending -> "Esperando procesamiento"
}
fun main() {
println(describe(PaymentResult.Success("TX-1001", 250_000)))
println(describe(PaymentResult.Failed("Tarjeta vencida", retryable = false)))
println(describe(PaymentResult.Pending))
}
Resultado al ejecutar:
Pagado 250000 VND (código TX-1001)
Falló: Tarjeta vencida
Esperando procesamiento
Hay tres puntos dignos de atención en el código anterior:
- Smart cast: dentro de la rama
is PaymentResult.Success, la variableresultse convierte automáticamente aSuccess. Por lo tanto accedes aresult.amountsin necesidad de un cast manual. - Con
object, comparas directamente usandoPaymentResult.Pendingsin necesidad deis. Escribiris PaymentResult.Pendingtambién es correcto, pero innecesario. whenaquí es una expresión, es decir, retorna un valor y se usa como cuerpo de la función. Una expresiónwhendebe ser exhaustiva.
4.2. El compilador atrapa errores cuando añades nuevos casos
Supongamos que añades un estado de reembolso:
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
data class Refunded(val transactionId: String) : PaymentResult()
}
La función describe inmediatamente no compila:
e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.
Este es precisamente el valor central de sealed class. Cada lugar en el código que use when en PaymentResult es señalado por el compilador. No puedes olvidar ningún lugar.
4.3. when como sentencia
Desde Kotlin 1.7, incluso cuando when se usa como una sentencia (sin aprovechar el valor retornado), el compilador aún reporta error si falta una rama. La condición es que el sujeto tiene tipo sealed, enum o Boolean:
fun log(result: PaymentResult) {
when (result) {
is PaymentResult.Success -> println("INFO pago ${result.transactionId}")
is PaymentResult.Failed -> println("WARN ${result.reason}")
PaymentResult.Pending -> println("DEBUG esperando")
is PaymentResult.Refunded -> println("INFO reembolso ${result.transactionId}")
}
}
Si omites la rama Refunded, obtienes un error de compilación, no solo una advertencia como en versiones anteriores de Kotlin.
5. Sealed interface
Una clase solo puede heredar de una clase padre, pero puede implementar múltiples interfaces. Por eso sealed interface es más flexible cuando un tipo pertenece a múltiples grupos simultáneamente:
sealed interface OrderEvent
sealed interface Notifiable {
val customerEmail: String
}
data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent
fun handle(event: OrderEvent): String = when (event) {
is OrderPlaced -> "Crear factura para ${event.orderId}"
is OrderShipped -> "Código de seguimiento ${event.trackingCode}"
is StockReserved -> "Reservar producto ${event.sku}"
}
fun emailOf(target: Notifiable): String = when (target) {
is OrderPlaced -> "Enviar confirmación a ${target.customerEmail}"
is OrderShipped -> "Enviar notificación de entrega a ${target.customerEmail}"
}
OrderPlaced es tanto OrderEvent como Notifiable. La función emailOf solo necesita dos ramas, porque el compilador sabe que Notifiable tiene exactamente dos implementadores.
Sealed interface también funciona con enum. Una enum class puede implementar un sealed interface, y cada constante enum se cuenta como una rama en when.
5.1. Múltiples niveles de anidamiento
Una subclase también puede ser sealed. Esto te permite agrupar con más detalle:
sealed interface PaymentError {
sealed interface Retryable : PaymentError
data object Timeout : Retryable
data object GatewayBusy : Retryable
data class CardDeclined(val code: String) : PaymentError
}
fun shouldRetry(error: PaymentError): Boolean = when (error) {
is PaymentError.Retryable -> true
is PaymentError.CardDeclined -> false
}
La rama is PaymentError.Retryable cubre tanto Timeout como GatewayBusy, así que when es exhaustivo con solo dos ramas.
6. Guard conditions en when
A veces el mismo tipo pero quieres manejar diferente según los datos internos. Kotlin 2.2 soporta oficialmente guard conditions (la palabra clave if después de la condición de la rama):
fun nextAction(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Enviar factura"
is PaymentResult.Failed if result.retryable -> "Reintentar en 30 segundos"
is PaymentResult.Failed -> "Reportar error al cliente"
PaymentResult.Pending -> "Esperar webhook del portal de pago"
is PaymentResult.Refunded -> "Actualizar libro mayor"
}
Una rama con guard solo coincide cuando tanto el tipo como la condición if son verdaderos. El compilador no considera que una rama con guard cubra Failed. Por lo tanto, si falta la rama is PaymentResult.Failed sin guard, aún obtienes un error exhaustivo.
Nota: En Kotlin 2.1, guard conditions era una característica en vista previa y requería la bandera
-Xwhen-guards. Verifica la versión de Kotlin de tu proyecto antes de usarla.
7. Combinado en un servicio
En código real, sealed class usualmente es el tipo de retorno de un servicio en lugar de lanzar excepciones:
data class Order(val id: String, val amount: Long)
class PaymentService {
fun charge(order: Order): PaymentResult = when {
order.amount <= 0 -> PaymentResult.Failed("Monto inválido", retryable = false)
order.amount > 50_000_000 -> PaymentResult.Pending
else -> PaymentResult.Success("TX-${order.id}", order.amount)
}
}
fun main() {
val service = PaymentService()
when (val result = service.charge(Order("A42", 120_000))) {
is PaymentResult.Success -> println("Código de transacción: ${result.transactionId}")
else -> println(describe(result))
}
}
Código de transacción: TX-A42
En la función charge, when no tiene sujeto así que necesita else. Aquí else es completamente razonable porque las condiciones son expresiones booleanas arbitrarias.
En main, la sintaxis when (val result = ...) tanto llama al servicio como limita el alcance de la variable result dentro del bloque when. La rama else aquí es intencional: solo te importa el caso de éxito. La sección 9 explicará el costo de esta elección.
8. Escribiendo pruebas
Gracias a data class, puedes comparar resultados usando isEqualTo muy limpiamente:
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class PaymentServiceTest {
private val service = PaymentService()
@Test
fun `orden válida retorna Success`() {
val result = service.charge(Order("A1", 99_000))
assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
}
@Test
fun `monto negativo retorna Failed sin reintento`() {
val result = service.charge(Order("A2", -5))
assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
assertThat((result as PaymentResult.Failed).retryable).isFalse()
}
@Test
fun `orden grande pasa a Pending`() {
assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
}
@Test
fun `error reintentable lleva a acción retry`() {
val action = nextAction(PaymentResult.Failed("Gateway timeout", retryable = true))
assertThat(action).isEqualTo("Reintentar en 30 segundos")
}
}
Este conjunto de pruebas verifica tres puntos:
- La primera prueba se basa en
equalsgenerado pordata classpara comparar todos los datos. isSameAsse usa paradata object, porquePendinges un singleton así que comparar referencias es suficiente.- La última prueba verifica una rama con guard condition.
9. Errores comunes y cuándo no usar sealed class
Agregar else a when sobre un tipo sealed. Este es el error más común. Una rama else “por seguridad” desactiva completamente el mecanismo de verificación exhaustiva. Cuando agregues una nueva subclase, el compilador permanecerá en silencio. Solo usa else cuando realmente quieras agrupar todos los casos restantes, como en el ejemplo de la sección 7.
Usar when sin sujeto. when { result is Success -> ... } no se verifica como exhaustivo según la jerarquía sealed. Escribe when (result) para que el compilador pueda analizar.
Olvidar data para object. Un object Pending normal aún funciona. Sin embargo, los logs imprimirán el nombre de la clase con hash code, dificultando la depuración.
Colocar subclases fuera del módulo. Si escribes una biblioteca y quieres que los usuarios la extiendan (por ejemplo, plugins), sealed class es la opción equivocada. Usa interface o abstract class normal.
Nota: No deberías usar sealed class cuando:
- Los casos son solo constantes sin datos, como
LOW,MEDIUM,HIGH. En este casoenum classes más compacto y tieneentries,valueOf.- La lista de subclases cambia muy frecuentemente y múltiples equipos en múltiples módulos contribuyen.
- El comportamiento debería estar en el objeto mismo (polimorfismo mediante method override) en lugar de esparcir
whenpor todo el código. Si el mismowhense repite en diez lugares, considera mover la lógica a métodos en cada subclase.
10. Conclusión
Sealed class y sealed interface te ayudan a bloquear la jerarquía de herencia en un módulo. Gracias a esto el compilador conoce completamente todos los casos. Combinado con when con sujeto, obtienes smart cast, no necesitas else, y recibes un error de compilación si olvidas manejar un nuevo estado.
Usa data class para casos que lleven datos y data object para casos sin datos. Elige sealed interface cuando un tipo pertenece a múltiples grupos. Las guard conditions de Kotlin 2.2 ayudan a separar ramas según datos internos. Lo más importante es evitar ramas else innecesarias, porque pierden precisamente el beneficio que buscas.