Hacer imposibles los estados inválidos
Un State con booleanos correlacionados es una promesa que el compilador no verifica: tú sabes que cargando y error no deberían ser ciertos a la vez, pero el tipo permite esa combinación y tarde o temprano alguien la escribe. Esta lección aplica el álgebra de tipos al modelado del State —contar cuántos valores admite un tipo y comparar ese número con los que el dominio acepta— y muestra cómo el enum con valores asociados colapsa el espacio de estados hasta hacerlo coincidir con el dominio. El ejemplo canónico de cargando, error y datos sirve de hilo, pero la técnica se generaliza: mover los datos dentro del caso que los justifica, expulsar los opcionales redundantes y convertir cada regla de negocio en una imposibilidad sintáctica.
Todo tipo tiene una cardinalidad: el número de valores distintos que puede tomar. Un Bool tiene dos, un Optional de Bool tiene tres, un struct con tres booleanos tiene ocho. Ese número es la medida exacta del espacio en el que tu programa puede encontrarse, y la comparación entre él y el número de situaciones que tu dominio realmente admite es el diagnóstico más honesto que existe sobre la calidad de un modelo. Si tu dominio tiene cuatro situaciones posibles y tu State admite treinta y dos, has firmado veintiocho promesas que nadie comprueba: veintiocho configuraciones que el compilador acepta, que tus tests no cubren y en las que tu vista tendrá que decidir qué dibujar improvisando. Hacer imposibles los estados inválidos no es una consigna estética; es la operación técnica de reducir esa cardinalidad hasta que coincida con la del dominio, y en Swift se hace con una herramienta concreta: el enum con valores asociados.
- Contar la cardinalidad de un
Statey compararla con la del dominio que pretende representar. - Identificar booleanos correlacionados y opcionales redundantes como síntoma de un tipo demasiado ancho.
- Sustituir campos sueltos por un
enumcon valores asociados que ate cada dato al caso que lo justifica. - Escribir reducers y vistas cuyo
switchexhaustivo demuestre que no queda ninguna rama contradictoria.
La aritmética de un State mal modelado
Empecemos por medir. Un State es un tipo producto: su cardinalidad es el producto de las cardinalidades de sus campos. Un enum es un tipo suma: su cardinalidad es la suma de las de sus casos. Con esas dos reglas se puede auditar cualquier modelo sin ejecutarlo.
struct State: Equatable {
var cargando: Bool = false
var refrescando: Bool = false
var articulos: [Articulo] = []
var error: String? = nil
}
Ignorando por un momento la cardinalidad enorme de la lista y del texto, los dos booleanos y la opcionalidad del error ya generan ocho combinaciones estructurales. ¿Cuántas describen algo real? Cargando con error presente no significa nada. Cargando y refrescando a la vez tampoco. Datos vacíos con error nulo es ambiguo: ¿todavía no se pidió, o se pidió y no había nada? El dominio tiene cuatro situaciones —no se ha pedido, se está pidiendo, llegó una lista, falló— y el tipo ofrece ocho ramas estructurales cruzadas con una lista que puede estar poblada o vacía en cualquiera de ellas. La distancia entre las cuatro y las ocho no es un detalle: es la superficie donde vive el bug.
El síntoma clínico de este problema son los booleanos correlacionados: dos o más campos que no son independientes, pero cuyo tipo los declara independientes. En cuanto tienes que escribir un comentario del estilo si cargando es cierto, error debe ser nulo, has descubierto un invariante que el sistema de tipos podría estar sosteniendo por ti y que has decidido sostener a mano. Ese comentario es una deuda, y el interés lo paga quien toque el reducer dentro de seis meses.
Hay un segundo síntoma, más silencioso, que aparece en la vista. Cuando para decidir qué dibujar hace falta consultar tres campos en una cadena de condiciones anidadas, y encima el último else dibuja algo defensivo por si acaso, esa rama defensiva es la confesión escrita de que el tipo admite situaciones que nadie sabe interpretar. Nadie escribe un else inexplicable por gusto: lo escribe porque el compilador le obligó a cubrir un caso que el dominio no contempla y no había nada sensato que poner ahí. Ese hueco es el bug futuro, ya construido, esperando a que un orden de llegada inusual lo alcance.
El opcional redundante es la tercera forma del mismo problema. Un String? que solo tiene valor cuando otro campo está en cierto estado declara que puede tener valor siempre, y obliga a todo el código que lo lee a desenvolverlo con una rama que en la práctica jamás se toma. Esas ramas nunca ejercitadas son terreno muerto: no se testean, no se revisan y acumulan suposiciones que envejecen mal.
El enum que colapsa el espacio
La corrección no consiste en añadir validaciones, sino en cambiar la forma del tipo. Si las cuatro situaciones son mutuamente excluyentes, el tipo que las representa es una suma, y cada dato debe vivir dentro del caso que lo hace legítimo.
@ObservableState
struct State: Equatable {
var carga: Carga = .noSolicitada
@CasePathable
enum Carga: Equatable {
case noSolicitada
case cargando
case cargada(IdentifiedArrayOf<Articulo>)
case fallida(MensajeError)
}
}
La cardinalidad estructural pasó de ocho a cuatro, y algo más importante ocurrió con los datos: la lista ya no existe fuera del caso cargada y el mensaje de error no existe fuera de fallida. No es que sea incorrecto leerlos en la fase equivocada; es que no se puede escribir el código que los lea. El acceso a los datos exige antes demostrar, con un case del switch, que estás en la fase que los contiene. La comprobación se hizo estructural.
case .articulosRecibidos(let articulos):
state.carga = .cargada(IdentifiedArrayOf(uniqueElements: articulos))
return .none
case .fallo(let error):
state.carga = .fallida(MensajeError(error))
return .none
El reducer se vuelve una función total sobre el enum: cada acción reasigna la fase completa en lugar de tocar tres campos y confiar en que la combinación resultante tenga sentido. Nótese el cambio de gesto: antes se actualizaban campos, ahora se sustituye la situación. La transición es atómica por construcción y no hay ningún instante intermedio en el que dos campos se contradigan.
El error clásico al aplicar esta técnica es dejar fuera del enum un booleano que parecía ortogonal, como refrescando. Casi nunca lo es: refrescar solo tiene sentido cuando ya hay datos. Modélalo como case refrescando(IdentifiedArrayOf<Articulo>), con los datos antiguos dentro, y ganarás gratis lo que antes exigía lógica: la vista puede seguir mostrando la lista vieja durante el refresco porque el tipo garantiza que existe.
El switch exhaustivo como demostración
El pago del enum llega en las dos capas que consumen el State. En la vista, el switch cubre exactamente las fases legítimas y el compilador se niega a compilar si aparece una nueva y no la atiendes.
switch store.carga {
case .noSolicitada:
ContenidoVacio()
case .cargando:
ProgressView()
case .cargada(let articulos):
Lista(articulos: articulos)
case .fallida(let mensaje):
VistaDeError(mensaje: mensaje)
}
No hay ninguna rama con condiciones cruzadas, ninguna consulta a dos campos para decidir uno, ningún else defensivo que dibuje algo por si acaso. Y en los tests, el TestStore ya no tiene que afirmar tres mutaciones separadas por transición sino una sola, lo que además elimina la posibilidad de que un test pase porque afirmaste dos de los tres cambios.
Producto: lo que coexiste
Un struct multiplica cardinalidades. Úsalo solo cuando todos sus campos pueden variar de verdad de forma independiente, sin invariantes que los aten.
Suma: lo que se excluye
Un enum las suma. Úsalo en cuanto detectes que dos campos no pueden ser ciertos a la vez, y mete dentro de cada caso los datos que solo ese caso justifica.
Un matiz que evita el celo mal dirigido: no todo enum mejora un modelo, y no todo campo booleano es un pecado. Un Bool es perfectamente legítimo cuando representa una dimensión genuinamente independiente de las demás —una preferencia del usuario, un interruptor que puede estar en cualquier posición sea cual sea el resto—. El problema nunca fue el booleano, sino el booleano correlacionado. La prueba es directa: si puedes cambiar ese campo dejando todos los demás como estaban y el valor resultante sigue describiendo una situación real, el campo es independiente y merece existir por separado.
Cuando el enum no basta: constructores que validan
El enum colapsa la cardinalidad estructural, pero deja intacta la de los datos que hay dentro de cada caso. case cargada(IdentifiedArrayOf<Articulo>) no impide que la colección esté vacía, y si tu dominio distingue cargó y había resultados de cargó y no había ninguno, ese matiz sigue sin estar en el tipo y volverá a resolverse con un isEmpty disperso por la vista y el reducer. La salida es la misma técnica aplicada un nivel más abajo: o bien un caso adicional que nombre la situación —case vacia—, o bien un tipo que solo pueda construirse cuando la condición se cumple.
struct ListaNoVacia<Elemento>: Equatable where Elemento: Equatable {
let primero: Elemento
let resto: [Elemento]
init?(_ elementos: [Elemento]) {
guard let primero = elementos.first else { return nil }
self.primero = primero
self.resto = Array(elementos.dropFirst())
}
}
El inicializador fallible concentra la validación en un único punto: una vez tienes una ListaNoVacia, ninguna parte del programa necesita volver a comprobar que no está vacía, porque el tipo lo demuestra. Es el desplazamiento de validar a hacer imposible llevado al dato mismo, y se generaliza a todo lo que hoy compruebas repetidamente: correos con formato, cantidades positivas, códigos con longitud fija. Cada comprobación que se repite en más de un sitio es un tipo que falta.
Cada tipo refinado añade una conversión en la frontera —donde llegan los datos del exterior— y algo de ceremonia al construir valores en los tests. Aplícalo donde la condición se comprueba muchas veces o donde violarla causa daño; no conviertas cada String de tu dominio en un tipo propio o el modelo se volverá impracticable por exceso de virtud.
flowchart LR A[Tres campos sueltos] -->|ocho combinaciones| B[Cuatro validas y cuatro absurdas] B -->|refactor a enum| C[Enum de cuatro casos] C --> D[Switch exhaustivo en vista y reducer] D --> E[Ramas contradictorias imposibles de escribir] style A fill:#f38ba8,color:#11111b style C fill:#a6e3a1,color:#11111b
La técnica del enum se enseña siempre con el mismo ejemplo de carga y por eso se recuerda como un truco para pantallas de red. No lo es: es la aplicación local de una idea que gobierna todo el diseño de tipos, y que se puede enunciar sin metáforas. Un tipo es una proposición sobre qué valores son admisibles, y el compilador es el único revisor que comprueba todas tus proposiciones en todas las líneas, sin cansarse, en cada build. Cuando escribes el invariante en un comentario, lo estás sacando del ámbito del revisor incansable y metiéndolo en el de la disciplina humana, que falla. Cuando lo escribes en la forma del tipo, no puede fallar, porque el código que lo violaría no existe como texto compilable. Esa es la diferencia entre validar y hacer imposible, y es una diferencia de naturaleza, no de grado. Hay un segundo efecto, más silencioso y quizá más valioso: el proceso de colapsar la cardinalidad te obliga a averiguar cuál es de verdad tu dominio. Casi nunca sabes cuántas situaciones admite tu feature hasta que intentas enumerarlas como casos de un enum y descubres que había una que nadie había nombrado —lo que pasa cuando el refresco falla pero hay datos viejos, lo que pasa cuando la lista llega vacía— y que estabas dibujando por accidente. El enum no solo impide los estados absurdos: revela los estados legítimos que tu modelo estaba tratando como casos degenerados. Por eso el tiempo dedicado a este refactor no se recupera solo en bugs evitados, sino en decisiones de producto que nadie había tomado y que ahora, obligatoriamente, se toman. El compilador deja de ser un notario que registra tus errores y pasa a ser el interlocutor que te fuerza a tener un dominio.
- Elige una pantalla con al menos dos booleanos y un opcional en su
State. Calcula la cardinalidad estructural multiplicando las de sus campos. - Enumera por escrito las situaciones que tu dominio realmente admite. Resta ambos números: eso es la deuda que llevas encima.
- Busca los invariantes que hoy viven en comentarios o en la cabeza del equipo. Cada uno es un candidato a caso de
enum. - Reescribe los campos como un
enumcon valores asociados, moviendo cada dato dentro del caso que lo justifica. Comprueba que ya no hay ningún opcional redundante. - Reescribe la vista con un
switchsindefaulty el reducer con asignaciones de fase completa. Si algún caso te obliga a decidir algo que nunca habías decidido, has encontrado un requisito que faltaba.