Hacer imposibles los estados inválidos
La técnica de modelado más valiosa que Elm ha aportado a la cultura del software no es una biblioteca ni una sintaxis, sino una disciplina: diseñar los tipos de modo que las situaciones ilegales del dominio no puedan siquiera escribirse. Esta lección la desarrolla paso a paso y con método. Empieza por el diagnóstico cuantitativo —contar cuántos valores admite un tipo y compararlos con cuántas situaciones tiene el dominio, porque cada valor sobrante es un estado ilegal que alguien defenderá a mano—, sigue con la refactorización concreta de un record de banderas y campos opcionales hacia una unión etiquetada donde cada dato vive dentro de la variante que lo justifica, y continúa con casos que exigen más oficio: la lista no vacía, la selección que siempre apunta a algo existente, y el tipo opaco que traslada al módulo la vigilancia de un invariante que el tipo por sí solo no alcanza a expresar. Cierra situando la técnica en su marco: la corrección por construcción, la consigna de analizar en lugar de validar, y el criterio para saber cuándo el rigor deja de rendir.
Las tres lecciones anteriores entregaron las herramientas; esta es la que explica para qué sirven. Existe una técnica de modelado que la comunidad de Elm popularizó hasta convertirla en su seña de identidad y que después viajó, como suele ocurrir con las buenas ideas, a Rust, a Swift, a TypeScript y a cualquier lenguaje con uniones cerradas. Se enuncia en cinco palabras —hacer imposibles los estados inválidos— y consiste en diseñar los tipos de manera que las situaciones que el dominio no admite ni siquiera se puedan escribir. No que se detecten, no que se validen, no que se documenten en un comentario: que no existan como valores. El desplazamiento es sutil y transforma la práctica entera. En el modo habitual, un programa admite estados absurdos y se defiende de ellos con comprobaciones dispersas por todas partes; en este otro, el estado absurdo es rechazado por el compilador antes de que ninguna comprobación haga falta. La diferencia entre ambas posturas no es de estilo, es de dónde vive la corrección: en la vigilancia de las personas o en la forma de los datos.
- Diagnosticar un modelo contando cuántos valores admite frente a cuántas situaciones tiene el dominio.
- Refactorizar un
recordde banderas y campos opcionales hacia una unión etiquetada, paso a paso. - Modelar invariantes estructurales como la lista no vacía o la selección que siempre apunta a algo.
- Reconocer cuándo el tipo no basta y hay que recurrir a un tipo opaco vigilado por su módulo.
El diagnóstico: contar antes de rediseñar
La técnica empieza con una operación mecánica que casi nadie hace y que resulta reveladora: contar. Toma un tipo, calcula cuántos valores distintos admite y compáralo con cuántas situaciones distintas tiene el dominio que pretende representar. Si el primer número supera al segundo, la diferencia es exactamente el número de estados ilegales que tu programa puede alcanzar, y alguien —tú, en cada lectura de ese dato— tendrá que impedirlo a mano.
-- Cuatro campos: dos booleanos por dos opcionales
type alias Model =
{ cargando : Bool
, error : Maybe String
, usuarios : List Usuario
, seleccionado : Maybe Usuario
}
Ese record tiene dos por muchos por muchos por muchos habitantes, y las situaciones reales de la pantalla son cuatro: nada pedido, esperando, llegaron datos, falló. Al margen del cálculo exacto, la observación importante es cualitativa. El modelo permite que cargando sea verdadero mientras error tiene contenido; permite datos vacíos sin error y sin carga, que no distingue el aún no pedí del pedí y no había; y permite lo más peligroso de todo, que seleccionado apunte a un usuario que no está en la lista. Ninguna de esas combinaciones significa nada, todas compilan, y cada una es un fallo esperando su turno.
La pregunta de diseño que abre el trabajo es siempre la misma y conviene formularla en voz alta: ¿qué combinaciones de estos campos no pueden ocurrir nunca? Cada respuesta es un invariante, y cada invariante que no esté codificado en el tipo es una regla que vive en la cabeza de alguien y que se perderá con la primera persona que deje el equipo.
La refactorización, paso a paso
El procedimiento tiene tres movimientos y se aplica casi mecánicamente una vez que se ha visto un par de veces.
El primero es agrupar los campos correlacionados. cargando, error y usuarios no son tres datos independientes: son tres caras de una sola pregunta, en qué punto está la petición. Que estén sueltos en el record es precisamente el error, porque la estructura plana afirma una independencia que no existe.
El segundo es convertir ese grupo en un tipo suma, con una variante por situación real. Y el tercero, que es donde se juega todo, consiste en meter cada dato dentro de la variante que lo justifica, en lugar de dejarlo fuera al alcance de todos.
type Peticion
= Inactiva
| Cargando
| Fallida String
| Cargada (List Usuario)
type alias Model =
{ peticion : Peticion }
Compara el antes y el después con atención. Antes, usuarios era accesible siempre, incluso mientras se cargaba, y la vista tenía que decidir un orden de comprobaciones para saber si mirarlo. Ahora la lista solo existe dentro de Cargada, y solo se puede llegar a ella pasando por un case. El error solo existe dentro de Fallida. Y la combinación cargando con error, que antes era un valor perfectamente construible, ha dejado de tener representación posible: no es que esté prohibida, es que no hay forma de escribirla.
Queda el invariante más interesante, el del elemento seleccionado. Un Maybe Usuario colocado al lado de la lista no garantiza que el seleccionado pertenezca a ella; la manera de garantizarlo es que la selección viva dentro del caso que tiene lista, y que se exprese como una posición dentro de la estructura y no como una copia suelta del dato.
-- La seleccion solo existe donde hay lista, y por tanto no puede colgar
type Peticion
= Inactiva
| Cargando
| Fallida String
| Cargada Seleccion
type alias Seleccion =
{ anteriores : List Usuario
, actual : Usuario
, siguientes : List Usuario
}
Ese record de tres campos, conocido en la comunidad como lista con foco o SelectList, codifica dos invariantes simultáneos: siempre hay al menos un elemento, y siempre hay exactamente uno seleccionado, que además pertenece a la colección porque es la colección quien lo contiene. La lista vacía, la selección ausente y la selección colgada dejan las tres de ser expresables en el mismo movimiento.
flowchart LR A[Record plano con banderas y opcionales] --> B[Agrupar campos correlacionados] B --> C[Convertirlos en una union etiquetada] C --> D[Meter cada dato dentro de su variante] D --> E[Los estados ilegales no tienen representacion] style A fill:#f38ba8,color:#11111b style E fill:#a6e3a1,color:#11111b
Contar habitantes
Compara los valores que el tipo admite con las situaciones que el dominio tiene. La diferencia son estados ilegales.
Agrupar lo correlacionado
Campos que cambian juntos no son independientes. Sueltos en un record mienten sobre su relación.
Datos dentro de la variante
Un dato que solo tiene sentido bajo cierta condición debe vivir dentro de la variante que representa esa condición.
Tipo opaco
Cuando el invariante excede lo que el tipo expresa, se esconden los constructores y el módulo vigila la puerta.
Cuando el tipo no llega: constructores escondidos
Hay invariantes que un tipo suma no puede expresar por sí solo. Un correo electrónico bien formado, una temperatura no negativa, una contraseña con longitud mínima: en todos ellos la representación es un String o un Int corriente y la validez depende de una condición que el sistema de tipos no sabe comprobar. La técnica sigue funcionando, pero cambia de nivel: en vez de confiar en el tipo, se confía en el módulo.
module Correo exposing (Correo, desdeTexto, aTexto)
type Correo
= Correo String
desdeTexto : String -> Maybe Correo
desdeTexto texto =
if String.contains "@" texto then
Just (Correo texto)
else
Nothing
El mismo patrón sirve para cualquier invariante que dependa de una condición sobre el contenido y no sobre la forma, y su implementación siempre tiene la misma silueta: un tipo con un único constructor privado, una función de entrada que puede fallar y un puñado de funciones de lectura que no.
module Positivo exposing (Positivo, desde, valor)
type Positivo
= Positivo Int
desde : Int -> Maybe Positivo
desde n =
if n > 0 then
Just (Positivo n)
else
Nothing
valor : Positivo -> Int
valor (Positivo n) =
n
Al exponer Correo sin sus constructores —eso es lo que significa escribir el nombre a secas en la lista de exposing— el módulo se convierte en el único lugar del programa capaz de fabricar un valor de ese tipo. Fuera de él, la única puerta de entrada es desdeTexto, que devuelve un Maybe y por tanto obliga a afrontar el caso inválido. El invariante deja de ser una promesa repartida por toda la aplicación y pasa a estar concentrado en diez líneas que se pueden leer y auditar de una vez. Y hay un dividendo silencioso: una función que recibe un Correo ya no necesita validarlo, porque su tipo es la prueba de que alguien lo validó en la frontera.
Ningún principio se sostiene sin su contraparte. Modelar estrechamente cuesta más por adelantado, produce refactorizaciones más ruidosas cuando el dominio cambia y puede llevar a tipos barrocos si se persigue la perfección más allá de lo razonable. El equilibrio sensato es proporcional al riesgo y a la vida esperada del código: en el núcleo del dominio, donde los invariantes importan y el código durará años, el rigor se paga solo; en un prototipo que se tira el viernes, es ceremonia. La forma adulta de sostener la técnica no es hacer imposibles todos los estados inválidos, sino saber cuáles duelen de verdad y hacer imposibles esos.
El desplazamiento que propone esta técnica es tan profundo que cuesta verlo mientras se está dentro de él. En la práctica habitual del software, la corrección de un programa es una propiedad que las personas sostienen. El modelo admite estados absurdos; el equipo se compromete a no producirlos; se escriben comprobaciones defensivas, se añaden pruebas, se documentan reglas en el wiki, y todo ello funciona razonablemente bien hasta que el sistema crece, entra gente nueva, se toca un rincón que nadie recordaba y una de las reglas no escritas se rompe sin que nada chille. Todo ese aparato existe para compensar una decisión anterior, que fue elegir una representación más ancha que el dominio. La técnica de esta lección propone atacar la causa en lugar del síntoma: si el estado ilegal no tiene representación, no hay nada que vigilar, ninguna comprobación que recordar, ninguna prueba que escribir, ningún comentario que se desactualice. La corrección migra de la disciplina de las personas a la forma de los datos, y las formas no se cansan ni se olvidan ni cambian de empresa. Alexis King formuló la versión general de esta idea en una consigna que se ha vuelto célebre y que ilumina lo mismo desde otro ángulo: analiza, no valides. Validar es comprobar una condición y seguir adelante con el mismo tipo de antes, de modo que la información obtenida por la comprobación se pierde en el acto y el siguiente que reciba ese dato tendrá que volver a comprobarlo, porque su tipo no recuerda nada. Analizar es transformar el dato en otro cuyo tipo ya codifica lo comprobado, de manera que el conocimiento queda grabado en la estructura y no puede evaporarse. La diferencia entre una función que recibe un String y comprueba que sea un correo y otra que recibe un Correo no es de comodidad: es que la segunda ha subido la comprobación a la frontera del sistema y la ha hecho una sola vez para siempre. Y hay una consecuencia arquitectónica que va más lejos que cualquier truco de tipos. Los estados ilegales tienden a concentrarse en las fronteras —lo que llega por la red, lo que teclea un usuario, lo que hay en la base de datos desde hace seis años— y el diseño que sale de aplicar esta disciplina en serio tiene siempre la misma silueta: un borde estrecho donde todo lo dudoso se analiza y se convierte en tipos precisos, y un núcleo amplio donde ya no hay nada que validar porque nada inválido pudo entrar. Ese es el sentido profundo de la frase de que en Elm no hay errores en tiempo de ejecución. No es que el lenguaje sea mágico ni que el compilador demuestre teoremas: es que la cultura que creció alrededor del lenguaje enseñó a colocar la comprobación en un solo sitio y a codificar su resultado en un tipo, con lo cual el resto del programa opera sobre datos que no pueden estar mal. La técnica es transferible entera a cualquier lenguaje con uniones cerradas y encapsulación, y a ninguno le sobra.
- Toma el
recordde cuatro campos de la lección, enumera al menos cinco combinaciones sin sentido que admite y señala qué comprobación las impide hoy en tu código. - Aplica los tres movimientos —agrupar, sumar, meter los datos dentro— y comprueba que cada una de esas combinaciones ha dejado de ser expresable.
- Escribe la vista que consume el modelo nuevo y verifica que no contiene ni una sola comprobación defensiva.
- Modela una pantalla con pestañas de modo que sea imposible tener cero pestañas o ninguna activa; escribe la firma del tipo antes que su implementación.
- Implementa
type NonEmpty acon un constructor que lleve el primer elemento y el resto, y una función de cabeza que no devuelvaMaybe; explica por qué puede permitírselo. - Crea un módulo
Correocon constructor escondido y una función de entrada que devuelvaMaybe; intenta construir unCorreodesde fuera y lee el error. - Busca en un proyecto propio dos campos que siempre cambien juntos y refactorízalos a una unión; anota cuántas comprobaciones desaparecieron al hacerlo.