sealed class y sealed interface: jerarquías cerradas
Qué cierra exactamente la palabra sealed, dónde pueden vivir las subclases permitidas según el paquete y el módulo, y la diferencia real entre sellar una clase y sellar una interfaz cuando un tipo debe pertenecer a más de una jerarquía.
Sellar un tipo es firmar un contrato con el compilador: prometes que la lista de subtipos directos está completa aquí y ahora, y a cambio el compilador acepta razonar sobre esa lista como si fuera exhaustiva. No es un modificador de visibilidad ni una optimización; es una declaración sobre el conocimiento disponible en tiempo de compilación. Todo lo que hace valioso a sealed (la exhaustividad, el modelado de estados, el refactor seguro) sale de esa promesa, y por eso conviene entender con precisión qué cierra y qué deja abierto.
- Entender que
sealedrestringe quién puede heredar, no quién puede instanciar ni quién puede ver. - Conocer las reglas de ubicación de las subclases: mismo paquete y misma unidad de compilación.
- Distinguir
sealed classdesealed interfacey saber cuándo cada una es la correcta. - Combinar
object,data classy anidamiento para dar forma a una jerarquía legible.
Qué cierra exactamente sealed
Una clase sellada es implícitamente abstracta y su constructor es efectivamente inaccesible desde fuera de la jerarquía: no puedes instanciarla directamente y no puedes heredarla desde otro módulo. El compilador conoce la lista completa de subtipos directos y la guarda en los metadatos del tipo.
sealed class Pago {
object Efectivo : Pago()
data class Tarjeta(val ultimos4: String) : Pago()
data class Transferencia(val iban: String, val concepto: String?) : Pago()
}
Tres detalles que sostienen todo lo demás. El cierre es sobre los subtipos directos: una subclase puede a su vez ser open y ser heredada libremente por terceros, y eso no rompe la exhaustividad porque cualquier nieto sigue siendo instancia de uno de los hijos conocidos. El cierre es de herencia, no de visibilidad: una jerarquía sellada puede ser perfectamente pública y formar parte de la API. Y el cierre es una propiedad estática: si alguien crea subtipos por reflexión o por proxy dinámico, el compilador ya no puede ayudarte.
Los casos sin datos se declaran object porque solo necesitan existir una vez; los casos con datos, data class, para obtener igualdad estructural y copy. Anidar las subclases dentro del padre es opcional pero suele merecer la pena: Pago.Tarjeta se lee solo, y el espacio de nombres queda ordenado.
Dónde pueden vivir las subclases
La regla evolucionó y conviene tenerla exacta. En las versiones antiguas de Kotlin las subclases debían estar en el mismo fichero que el padre. Desde Kotlin 1.5 la restricción se relajó a mismo paquete y misma unidad de compilación: pueden repartirse en varios ficheros del mismo módulo, siempre que compartan paquete. Para las interfaces selladas la regla es equivalente.
// Fichero: dominio/Evento.kt
package dominio
sealed interface Evento
// Fichero: dominio/EventosDeSesion.kt (mismo paquete, mismo modulo)
package dominio
data class SesionIniciada(val usuario: String) : Evento
data object SesionCerrada : Evento
La consecuencia práctica es doble. Por un lado, una jerarquía grande deja de vivir en un fichero de mil líneas. Por otro, y esto es lo importante, nadie fuera de tu módulo puede añadir un caso: aunque publiques la interfaz, un consumidor no podrá implementarla. El cierre es por tanto una decisión de arquitectura sobre quién es dueño del conjunto de casos, no un detalle de organización de ficheros.
Observa también el data object: desde Kotlin 1.9 permite que un caso sin datos tenga un toString y un equals coherentes con los data class hermanos, lo que evita la asimetría fea de imprimir un identificador de instancia en medio de un log de eventos.
sealed class frente a sealed interface
La diferencia no es estilística. Una clase sellada es una clase: aporta un constructor, puede llevar estado común en propiedades concretas, y como Kotlin no tiene herencia múltiple, un caso solo puede pertenecer a una jerarquía sellada de clases.
sealed class Nodo(val id: String) {
class Hoja(id: String, val valor: Int) : Nodo(id)
class Rama(id: String, val hijos: List<Nodo>) : Nodo(id)
}
Una interfaz sellada no tiene constructor y por tanto no impone estado, solo contrato: puede declarar propiedades abstractas y métodos con implementación por defecto. A cambio permite que un mismo tipo participe en varias jerarquías cerradas a la vez, e incluso que un enum class implemente la interfaz.
sealed interface Error {
val mensaje: String
}
sealed interface Reintentable
data class SinRed(override val mensaje: String) : Error, Reintentable
data class NoAutorizado(override val mensaje: String) : Error
enum class Validacion(override val mensaje: String) : Error {
CAMPO_VACIO("el campo esta vacio"),
FORMATO("formato invalido"),
}
Esa última capacidad de cruzar clasificaciones es la razón principal para preferir sealed interface por defecto en modelado de dominio: puedes clasificar los mismos casos por más de un eje sin duplicar la jerarquía. Reserva sealed class para cuando de verdad quieras imponer estado o inicialización comunes a todos los casos.
Sella interfaces por defecto
Sin constructor, sin estado impuesto y con herencia múltiple: una interfaz sellada permite que un caso pertenezca a varias clasificaciones cerradas a la vez.
Sella clases cuando hay estado común
Si todos los casos comparten de verdad una propiedad con valor, la clase sellada la declara una vez y garantiza su inicialización en el constructor.
Mismo paquete, mismo módulo
Las subclases ya no necesitan compartir fichero, pero sí paquete y unidad de compilación. Fuera de tu módulo nadie puede añadir un caso.
Usa data object para casos sin datos
Un caso sin payload merece igualdad e impresión coherentes con sus hermanos data class, sobre todo en logs y en pruebas.
La forma de la jerarquía
flowchart TD A[sealed interface Resultado] --> B[data class Exito] A --> C[sealed interface Fallo] C --> D[data class ErrorDeRed] C --> E[data class ErrorDeDatos] C --> F[object Cancelado] B --> G[Cada caso lleva solo sus propios datos] D --> H[Los fallos se agrupan sin perder el cierre] style A fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b
Las jerarquías selladas admiten genéricos, y ahí aparece uno de los idiomas más elegantes de Kotlin: los casos que no usan el parámetro de tipo se declaran como object que implementa la variante con Nothing, aprovechando que Nothing es subtipo de todo y que el parámetro es covariante.
sealed interface Resultado<out T> {
data class Exito<T>(val valor: T) : Resultado<T>
data class Fallo(val causa: Throwable) : Resultado<Nothing>
data object Pendiente : Resultado<Nothing>
}
// un Fallo encaja donde se espera cualquier Resultado, sin castear
val r: Resultado<String> = Resultado.Fallo(IllegalStateException("sin red"))
Las jerarquías selladas se anidan: un caso puede ser a su vez sellado, lo que permite tratar un grupo entero de casos como una unidad cuando conviene y descender al detalle cuando hace falta. Con la resolución sensible al contexto de Kotlin 2.3, referirte a esas subclases dentro de un when con tipo esperado conocido ya no exige repetir el nombre del padre en cada rama, así que anidar deja de penalizar la legibilidad.
La discusión sobre sealed casi siempre se plantea como una cuestión técnica de exhaustividad, y esa lectura se queda corta porque olvida quién paga la factura. Cuando sellas un tipo estás tomando una decisión sobre la propiedad del dominio: afirmas que el conjunto de casos es tuyo, que evolucionará contigo y que nadie fuera de tu módulo tiene derecho a ampliarlo. Es exactamente la decisión contraria a la de una interfaz abierta, donde regalas el eje de extensión a quien consuma tu código y renuncias para siempre a saber cuántos subtipos existen. Ninguna de las dos es mejor en abstracto y el error es elegir por costumbre. Un modelo de dominio interno, una máquina de estados, el resultado de una operación, el árbol de una gramática o los eventos de una pantalla son conjuntos que el autor conoce por completo y que quiere poder ampliar rompiendo a propósito a todos sus consumidores para obligarles a considerar el caso nuevo: ahí sellar es un acierto rotundo. Un punto de extensión de una librería, una estrategia que terceros deben poder implementar o un plugin son justo lo contrario, y sellarlos convierte tu librería en una jaula. Fíjate en que la regla de ubicación (mismo paquete, misma unidad de compilación) no es un capricho del compilador sino la materialización de esa idea: el límite del cierre coincide exactamente con el límite de tu autoridad sobre el código. Puedes añadir casos porque compilas todo lo que los usa; no puedes permitir que otros los añadan porque entonces nadie podría garantizar que la lista está completa. Por eso la pregunta que decide entre sealed y open nunca es “cuántos casos hay” sino “quién debe poder inventar el siguiente”.
- Convierte el enum más anulable de tu código en una
sealed interfacecon un caso por forma distinta de dato. - Reparte los casos en dos ficheros del mismo paquete y comprueba que sigue compilando.
- Añade un segundo eje de clasificación con otra interfaz sellada y haz que dos casos la implementen.
- Declara un caso sin datos como
data objecty compara sutoStringcon el de unobjectnormal. - Intenta implementar tu interfaz sellada desde otro módulo y lee con atención el error del compilador: ahí está el contrato.