wandres.dev
SEALED Y EXHAUSTIVIDAD · modelar el dominio cerrado

Sealed frente a interfaces abiertas: qué pierdes al cerrar

El problema de la expresión aplicado al diseño de tipos: qué eje de extensión regalas al sellar y cuál regalas al abrir, el coste de añadir un caso en una librería publicada, y un criterio para elegir según quién debe poder extender.

⏱ 15 min

Cerrar una jerarquía tiene un precio y casi nunca se paga el día que se decide. Se paga meses después, cuando alguien de fuera necesita un caso que tu modelo no contempla y descubre que no puede añadirlo, o cuando publicas una versión con un caso nuevo y rompes a todos tus consumidores. Ninguna de las dos cosas convierte a sealed en un error: son la consecuencia exacta de lo que se prometió. Esta lección va de entender el intercambio con precisión suficiente para elegir a conciencia en lugar de por costumbre.

🎯 Al terminar esta lección sabrás
  • Formular el intercambio entre jerarquía cerrada y jerarquía abierta como dos ejes de extensión opuestos.
  • Reconocer el problema de la expresión detrás de esa elección.
  • Evaluar el impacto de añadir un caso según sea código propio o API publicada.
  • Aplicar un criterio de decisión basado en quién debe poder extender.

Dos ejes de extensión, y solo puedes tener uno fácil

Todo tipo con varios casos crece en dos direcciones: puedes añadir casos nuevos o añadir operaciones nuevas sobre los casos existentes. Ese conflicto tiene nombre desde hace décadas, el problema de la expresión, y la elección entre sellar y abrir es exactamente su versión kotliniana.

Con una jerarquía sellada y when externos, añadir una operación es trivial: escribes otra función en cualquier módulo, sin tocar la jerarquía. Añadir un caso, en cambio, obliga a revisar todos los when, y no puedes hacerlo desde fuera del módulo dueño.

// operaciones nuevas: gratis, y sin tocar el tipo
fun Figura.area(): Double = when (this) { /* una rama por caso */ }
fun Figura.perimetro(): Double = when (this) { /* otra vez */ }

Con una interfaz abierta y métodos polimórficos ocurre lo contrario. Añadir un caso es trivial y lo puede hacer cualquiera desde cualquier módulo; añadir una operación al contrato rompe a todas las implementaciones existentes, incluidas las que no controlas.

interface Figura {
    fun area(): Double   // anadir aqui un metodo rompe a todo implementador ajeno
}

No hay forma de tener ambas direcciones baratas a la vez, así que la pregunta no es cuál es mejor sino cuál de las dos ampliaciones esperas hacer más y quién va a hacerla.

flowchart TD
A[Quien debe poder inventar el siguiente caso] --> B[Solo yo y mi modulo]
A --> C[Terceros que no controlo]
B --> D[Jerarquia sellada]
D --> E[Exhaustividad y operaciones externas baratas]
C --> F[Interfaz abierta]
F --> G[Casos nuevos sin tocar mi codigo]
G --> H[Coste: nunca sabre cuantos subtipos existen]
style D fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b

Lo que pierdes al cerrar

Conviene nombrar las pérdidas sin rodeos, porque la literatura sobre sealed suele contar solo la mitad buena.

Pierdes la extensibilidad externa: ningún consumidor podrá añadir un caso, ni siquiera uno legítimo que tú no previste, y su única salida será envolver tu tipo o duplicar la jerarquía. Pierdes flexibilidad para los tests, donde a veces resulta cómodo un caso falso que no debe existir en producción. Y asumes que la lógica por caso tenderá a vivir fuera del tipo, en when repartidos por el código, con el riesgo de que un mismo caso se trate de forma incoherente en dos módulos distintos.

Conviene conocer también la salida de emergencia que le queda al consumidor bloqueado, porque saber cuánto duele ayuda a calibrar la decisión. Quien necesita un caso que tu jerarquía no contempla solo puede envolver tu tipo en uno propio, y a partir de ahí vive traduciendo entre dos modelos paralelos en cada frontera. Funciona, pero duplica el mapa del dominio y convierte cada actualización de tu librería en trabajo de sincronización para él. Ese coste no aparece en tu compilación ni en tus pruebas, y por eso es fácil ignorarlo hasta que llega en forma de incidencia.

Hay una pérdida más sutil y más importante en librerías: añadir un caso deja de ser retrocompatible en el fuente. Para el código dentro de tu módulo eso es precisamente la ventaja, porque el compilador te obliga a revisar todo. Para un consumidor externo que actualiza la versión, es su compilación rota sin haber tocado una línea.

⚠️
Añadir un caso a una jerarquía sellada publicada es un cambio mayor

Si publicas una librería, la lista de subtipos forma parte de tu contrato igual que las firmas de los métodos. Un consumidor que escribió un when exhaustivo sin else dejará de compilar en cuanto añadas un caso, y si compiló contra la versión anterior podrá encontrarse en tiempo de ejecución con un subtipo que su código no contempla. Trátalo como un cambio de versión mayor, documenta explícitamente si la jerarquía puede crecer y, si prevés crecimiento frecuente, plantéate no sellar la parte pública.

🔒

Cerrado: operaciones baratas, casos caros

Añadir una función que hace un when nuevo no toca el tipo; añadir un caso obliga a revisar todos los when y solo puede hacerlo el módulo dueño.

🔓

Abierto: casos baratos, operaciones caras

Cualquiera implementa el contrato desde fuera, pero añadir un método al contrato rompe a implementadores que no controlas.

📚

Librería publicada, contrato ampliado

En una API pública la lista de subtipos forma parte del contrato. Si la jerarquía puede crecer, dilo en la documentación.

🧩

Sella dentro, abre fuera

Jerarquía sellada interna para tu lógica y una superficie pública basada en funciones de plegado: exhaustividad para ti, libertad para ellos.

Cómo elegir

El criterio útil no es técnico sino de propiedad: quién debe poder inventar el siguiente caso. Si la respuesta es “yo, dentro de mi módulo”, sella; si es “cualquiera, incluso gente que no conozco”, abre.

Sella cuando modelas resultados de una operación, estados de una pantalla o de un proceso, eventos de un flujo, errores de dominio, comandos, o el árbol sintáctico de una gramática. Son conjuntos que conoces por completo, cuya lista tiene sentido cerrar, y donde quieres que el compilador te obligue a revisar cada consumidor cuando crezcan.

Abre cuando diseñas puntos de extensión: estrategias que otros implementan, adaptadores de almacenamiento, formateadores, plugins, o cualquier contrato pensado precisamente para que el ecosistema lo amplíe. Ahí la exhaustividad no solo no aporta nada, sino que sería un obstáculo activo.

Existe además una tercera vía que funciona bien en librerías reales: sellar por dentro y abrir por fuera. Modelas el dominio con una jerarquía sellada interna y expones al consumidor una API basada en funciones o en un tipo abierto, de modo que tú conservas la exhaustividad donde vive la lógica y nadie se queda atrapado en tu lista de casos.

// dentro del modulo: cerrado, exhaustivo, con toda la logica
internal sealed interface RespuestaCruda {
    data class Ok(val cuerpo: String) : RespuestaCruda
    data class Redirigida(val destino: String) : RespuestaCruda
    data class Fallida(val codigo: Int) : RespuestaCruda
}

// hacia fuera: una API que no ata al consumidor a la lista de casos
fun <T> RespuestaCruda.plegar(
    onOk: (String) -> T,
    onError: (Int) -> T,
): T = when (this) {
    is RespuestaCruda.Ok -> onOk(cuerpo)
    is RespuestaCruda.Redirigida -> onOk("")
    is RespuestaCruda.Fallida -> onError(codigo)
}

La asimetría de cambiar de idea

Las dos decisiones no cuestan lo mismo cuando quieres revertirlas, y esa asimetría debería inclinar la elección por defecto en el código que aún no tiene consumidores externos.

Pasar de sellado a abierto es relativamente barato hacia fuera: nadie pierde nada, porque quien no podía extender ahora puede. El precio lo pagas tú, dentro: todos tus when exhaustivos dejan de serlo y hay que añadirles un else, con lo que pierdes de golpe la red que te avisaba de los casos nuevos. Es una degradación dolorosa pero mecánica y local.

Pasar de abierto a sellado es un cambio hostil hacia fuera: cualquier implementación existente en cualquier módulo ajeno deja de compilar, y no tienes forma de arreglarla porque no es tuya. Solo es viable si controlas a todos los consumidores o si aceptas una versión mayor con migración explícita.

Conviene además recordar que la garantía vive en el compilador de Kotlin y no en la máquina virtual. Desde Java, una jerarquía sellada de Kotlin se ve como una clase abstracta cualquiera y ningún switch te obligará a nada, así que en proyectos mixtos el cierre protege la parte kotliniana pero no valida al consumidor Java. La regla práctica que se deduce de todo esto es cómoda: empieza sellando lo que es tuyo, porque abrir después es un ajuste y cerrar después es una ruptura.

Un apunte de ergonomía que ya no pesa en la balanza: con la resolución sensible al contexto de Kotlin 2.3 puedes omitir el nombre del tipo en las ramas cuando el tipo esperado se conoce, así que el argumento clásico de que las jerarquías selladas anidadas resultan verbosas ha dejado de ser una razón seria para no sellar.

Sellar y abrir no son niveles de calidad: son declaraciones opuestas sobre quién manda

El error más caro de este nivel no es elegir mal, es no darse cuenta de que hay una elección. Quien acaba de descubrir la exhaustividad tiende a sellarlo todo y termina publicando librerías que nadie puede extender sin bifurcarlas; quien viene de años de diseño orientado a interfaces abre por reflejo hasta los tipos que solo usa él y renuncia a la única herramienta que le habría avisado al añadir un estado. Ambos aplican una preferencia estética a una decisión que en realidad es política: sellar declara que el conjunto de casos es tuyo y que evolucionará a tu ritmo, aunque eso rompa a quien te consume; abrir declara que cedes ese eje al ecosistema y aceptas para siempre no saber cuántos subtipos existen ahí fuera. Fíjate en la simetría exacta, porque es lo que hace útil el marco: en la jerarquía cerrada, añadir una operación es gratis y añadir un caso rompe a los consumidores; en la abierta, añadir un caso es gratis y añadir una operación rompe a los implementadores. En los dos mundos alguien se rompe, y lo único que decides es quién. Por eso el criterio operativo debe formularse mirando a las personas y no al código: nombra a quien va a escribir el siguiente caso y a quien va a escribir la siguiente operación; si ambos eres tú, sella sin dudarlo porque el compilador trabajará gratis para ti; si el primero es alguien a quien no puedes recompilar, abre y asume que perderás la exhaustividad; y si son personas distintas con necesidades distintas, no fuerces un solo tipo a servir a ambas, sella el núcleo donde vive tu lógica y ofrece hacia fuera una superficie que no ate a nadie a tu lista de casos. Un modelo de dominio no es una taxonomía neutral del mundo: es una afirmación sobre quién tiene derecho a cambiarlo.

⚔️ Decide con nombres y apellidos
  1. Elige tres tipos con varios casos de tu código y escribe, para cada uno, quién va a añadir el próximo caso.
  2. Marca cuáles están sellados sin necesidad y cuáles están abiertos aunque solo los uses tú.
  3. Coge una jerarquía sellada pública y simula añadir un caso: enumera qué consumidores externos romperías.
  4. Convierte una de esas jerarquías al patrón de cerrar por dentro y abrir por fuera con una función de plegado.
  5. Escribe en el README de un módulo si sus jerarquías selladas pueden crecer; esa frase es parte del contrato.