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 PaymentResult es una clase abstracta implícita. No puedes llamar PaymentResult() directamente. Su constructor por defecto es protected.
  • Success y Failed son data class. Obtienes automáticamente equals, hashCode, toString y copy, muy útil al escribir pruebas.
  • Pending no lleva datos, así que usamos data object. Es un singleton y tiene toString() que imprime Pending en lugar de algo como PaymentResult$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 variable result se convierte automáticamente a Success. Por lo tanto accedes a result.amount sin necesidad de un cast manual.
  • Con object, comparas directamente usando PaymentResult.Pending sin necesidad de is. Escribir is PaymentResult.Pending también es correcto, pero innecesario.
  • when aquí es una expresión, es decir, retorna un valor y se usa como cuerpo de la función. Una expresión when debe 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 equals generado por data class para comparar todos los datos.
  • isSameAs se usa para data object, porque Pending es 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 caso enum class es más compacto y tiene entries, 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 when por todo el código. Si el mismo when se 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.

kotlinsealed classwhenpattern matchingtype safety