wandres.dev
SEALED Y EXHAUSTIVIDAD · modelar el dominio cerrado

Enums: entradas con estado y sus límites

El enum como conjunto fijo de instancias: propiedades y métodos por entrada, cuerpos anónimos por constante, la propiedad entries frente al viejo values, y el punto exacto en el que un enum deja de servir como modelo del dominio.

⏱ 13 min

Un enum class es la manera más pequeña que tiene Kotlin de decir “los valores posibles son exactamente estos y no hay ninguno más”. No es una lista de constantes con nombre bonito: es un tipo cuyo conjunto de instancias está fijado en tiempo de compilación, y ese cierre es justo lo que permite al compilador razonar sobre él y exigirte que los cubras todos. Antes de saltar a sealed conviene exprimir el enum hasta encontrar su frontera, porque el día que la encuentres sabrás exactamente por qué existe la jerarquía sellada.

🎯 Al terminar esta lección sabrás
  • Entender el enum como un conjunto cerrado de instancias únicas, no como una lista de constantes.
  • Dar estado y comportamiento a las entradas, incluidos cuerpos propios por constante.
  • Sustituir values() por entries y saber qué cambia en asignaciones de memoria.
  • Reconocer el límite estructural del enum: todas las entradas comparten la misma forma.

Un conjunto cerrado de instancias

Cuando declaras un enum, cada entrada es una instancia única y perezosamente inicializada de la propia clase, un object con nombre generado por el compilador. No hay forma de crear otra: el constructor es privado por construcción y la identidad se puede comparar con === sin miedo.

enum class Prioridad { BAJA, MEDIA, ALTA }

val p: Prioridad = Prioridad.ALTA
println(p.name)     // ALTA
println(p.ordinal)  // 2

De ahí salen tres propiedades gratis que conviene conocer y respetar. name es el identificador textual, estable mientras no renombres la entrada. ordinal es la posición declarativa, y es exactamente el dato que nunca debes persistir ni enviar por la red: reordenar dos líneas del fichero cambia silenciosamente el significado de todos tus datos guardados. Y todo enum implementa Comparable, ordenado precisamente por ese ordinal, lo que hace que Prioridad.BAJA < Prioridad.ALTA compile y sea cierto por accidente del orden de escritura, no por diseño.

Convertir texto en entrada tiene dos caminos y solo uno es seguro por defecto. valueOf lanza una excepción cuando el nombre no existe, mientras que buscar sobre las entradas devuelve nulo y te obliga a decidir qué hacer con la ausencia.

val estricta = Prioridad.valueOf(texto)                   // lanza si no existe
val tolerante = Prioridad.entries.find { it.name == texto } // devuelve null

fun parsear(texto: String): Prioridad =
    Prioridad.entries.find { it.name.equals(texto, ignoreCase = true) } ?: Prioridad.MEDIA

La regla es la de siempre en las fronteras del sistema: si el dato viene de fuera del programa, la versión que devuelve nulo describe mejor la realidad que la que lanza.

Estado y comportamiento por entrada

Una entrada de enum puede llevar datos en el constructor y la clase puede tener métodos y propiedades como cualquier otra. Aquí es donde el enum deja de ser una etiqueta y empieza a ser un tipo de dominio.

enum class Moneda(val simbolo: String, val decimales: Int) {
    EUR("€", 2),
    USD("$", 2),
    JPY("¥", 0);

    val esFraccionable: Boolean get() = decimales > 0
}

El punto y coma tras la última entrada no es decorativo: separa la lista cerrada de entradas del cuerpo normal de la clase, y es obligatorio en cuanto añades cualquier miembro.

Kotlin permite además que cada constante tenga su propio cuerpo y sobrescriba miembros abstractos. Es potente y tiene una consecuencia que casi nadie ve: cada entrada con cuerpo genera una subclase anónima del enum, de modo que la jerarquía deja de ser plana.

enum class Operacion {
    SUMA {
        override fun aplicar(a: Int, b: Int) = a + b
    },
    RESTA {
        override fun aplicar(a: Int, b: Int) = a - b
    };

    abstract fun aplicar(a: Int, b: Int): Int
}

Ese estilo funciona bien cuando el comportamiento pertenece de verdad a la entrada y es cerrado. Cuando el comportamiento depende del módulo que consume el enum, un when externo suele envejecer mejor que un método sobrescrito, porque no obliga al tipo del dominio a conocer a todos sus clientes.

entries frente al viejo values

Durante años la única forma de recorrer un enum fue el método sintético values(), y arrastraba un defecto real: devuelve un Array, los arrays son mutables, y por eso el compilador tiene que copiar el array entero en cada llamada para que nadie pueda corromper el enum desde fuera. Un bucle que llame a values() en cada iteración va asignando basura sin que se note.

Desde Kotlin 1.9 existe la propiedad entries, estable y recomendada, que devuelve un EnumEntries, una lista inmutable de solo lectura creada una sola vez y cacheada.

// antiguo: nueva copia del array en cada llamada
val todas: Array<Prioridad> = Prioridad.values()

// moderno: lista inmutable cacheada, cero asignaciones por acceso
val entradas: List<Prioridad> = Prioridad.entries
val altas = Prioridad.entries.filter { it >= Prioridad.MEDIA }

// en código genérico con reified
inline fun <reified E : Enum<E>> nombres(): List<String> =
    enumEntries<E>().map { it.name }

Como entries ya es una List, obtienes first, map, indexOf y todo el resto del catálogo sin conversiones intermedias. La regla práctica es simple: en código nuevo, values() no debería aparecer nunca.

En la JVM hay un premio adicional por usar enums en estructuras de datos: EnumSet y EnumMap aprovechan que el conjunto de claves es fijo y conocido para representarse como máscaras de bits y como arrays indexados por ordinal. Son órdenes de magnitud más eficientes que un HashSet o un HashMap equivalentes, y esa optimización es posible precisamente por el cierre: es el primer sitio donde se ve que cerrar un tipo no solo compra corrección, también compra representación.

💡
Kotlin 2.3 y la resolución sensible al contexto

Con la resolución sensible al contexto mejorada en Kotlin 2.3, cuando el tipo esperado se conoce puedes omitir el nombre del enum al nombrar sus entradas. Dentro de un when cuyo sujeto es una Prioridad, escribir ALTA basta: el compilador sabe en qué espacio de nombres buscar. Sirve también en comparaciones y en argumentos con tipo declarado, y elimina de golpe la repetición mecánica del prefijo en las tablas de decisión largas.

Dónde deja de servir el enum

El límite del enum no es de sintaxis, es estructural: todas las entradas comparten exactamente la misma forma. Un mismo constructor, las mismas propiedades, los mismos tipos. En cuanto un caso necesita datos que otro no tiene, empiezan los campos anulables y con ellos los estados inválidos que el compilador ya no puede impedir.

// el enum empieza a mentir: dos de los tres campos sobran siempre
enum class Resultado(
    val datos: String? = null,
    val codigoError: Int? = null,
    val reintentos: Int? = null,
) {
    EXITO, FALLO, REINTENTANDO
}

Ese constructor con tres anulables no describe tres casos: describe un caso con tres huecos, y obliga a que cada consumidor recuerde qué hueco corresponde a qué entrada. Lo que suele venir después es un require en el cuerpo que comprueba en tiempo de ejecución que si la entrada es FALLO entonces codigoError no es nulo. Esa comprobación es la confesión escrita de que el tipo ya no representa el dominio.

Hay además una segunda frontera igual de dura: las entradas son singletons. No existe “un EXITO con estos datos concretos” y otro EXITO distinto, porque solo hay una instancia en todo el proceso. Tampoco puedes parametrizar un enum con genéricos ni hacer que un caso extienda algo que los demás no extienden.

flowchart TD
A[Necesito un conjunto cerrado de casos] --> B[Todos los casos tienen la misma forma]
B -->|si| C[Enum class]
B -->|no, cada caso lleva datos propios| D[Jerarquia sellada]
C --> E[Entradas unicas y comparables]
D --> F[Subclases con forma distinta]
style C fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b
El enum no es una lista de constantes: es la forma más pobre de un tipo suma

La intuición heredada de C o de Java describe el enum como azúcar sobre enteros con nombre, y esa intuición es exactamente la que impide entender el resto de este nivel. Un enum de Kotlin es un tipo suma degenerado: un tipo cuyo valor es uno de entre varios casos mutuamente excluyentes, con la restricción brutal de que todos los casos son la misma clase y por tanto ninguno puede llevar datos propios. Esa restricción es la que hace que el enum sea barato y también la que lo condena en cuanto el dominio crece. La señal de alarma es siempre la misma y aparece siempre igual de tarde: empiezan a aparecer propiedades anulables en el constructor, o un require en el cuerpo que valida que si la entrada es FALLO entonces codigoError no puede ser nulo. Eso es una comprobación en tiempo de ejecución para algo que el sistema de tipos podría garantizar en compilación, y es literalmente el fracaso del modelo. Cada vez que escribes esa validación estás pagando en runtime lo que un tipo suma real te habría dado gratis. La pregunta correcta ante cualquier conjunto cerrado de casos no es “cuántos casos hay” sino “todos los casos tienen la misma forma”: si la respuesta es sí, el enum es la herramienta perfecta y usar sealed sería ceremonia inútil; si es no, el enum va a obligarte a codificar la diferencia con nulos y con banderas, y a partir de ahí ninguna cantidad de disciplina evitará que alguien construya un valor imposible. Enum para conjuntos homogéneos, jerarquía sellada para casos heterogéneos: esa es toda la frontera, y reconocerla a tiempo ahorra refactorizaciones enteras.

⚔️ Encuentra la frontera del enum
  1. Coge un enum de tu código y cuenta cuántas de sus propiedades son anulables: cada una es un caso que no encaja en la forma común.
  2. Busca todos los values() del proyecto y sustitúyelos por entries; comprueba si alguno estaba dentro de un bucle.
  3. Escribe un enum con un método abstracto y cuerpos por constante, y razona qué clases genera realmente el compilador.
  4. Localiza cualquier sitio donde persistas u ordenes por ordinal y cámbialo por name o por un código explícito.
  5. Reescribe mentalmente el enum más anulable que tengas como una jerarquía de casos con formas distintas: eso es lo que construiremos en la lección siguiente.