En 2010, Yaron Minsky se plantó ante una sala de estudiantes de Harvard y enumeró nueve reglas para escribir OCaml efectivo. Una de ellas caló: “Make illegal states unrepresentable” (haz los estados ilegales irrepresentables).
Esa única frase ha moldeado cómo toda una generación de programadores piensa sobre tipos. Ha sido desarrollada por Scott Wlaschin para F#, reformulada como “Parse, don’t validate” por Alexis King para Haskell, e interiorizada por la comunidad Rust como un principio de diseño fundamental.
Pero ¿qué significa realmente? ¿Y por qué importa más allá de una preferencia de estilo?
Esta serie va más allá del consejo habitual de “usa un enum en vez de booleanos”. En esta primera parte, empezamos con el error más común: los flags booleanos.
El problema: un semáforo con booleanos #
Imagina que estás modelando un semáforo. El enfoque ingenuo:
struct TrafficLight {
is_red: bool,
is_yellow: bool,
is_green: bool,
}Tres booleanos. ¿Cuántos estados representa este tipo?
is_red |
is_yellow |
is_green |
Significado | ¿Válido? |
|---|---|---|---|---|
false |
false |
false |
Todos apagados | No |
true |
false |
false |
Rojo | Si |
false |
true |
false |
Amarillo | Si |
false |
false |
true |
Verde | Si |
true |
true |
false |
Rojo y amarillo | No |
true |
false |
true |
Rojo y verde | No |
false |
true |
true |
Amarillo y verde | No |
true |
true |
true |
Todos encendidos | No |
Ocho combinaciones, tres válidas. Cinco estados imposibles que el sistema de tipos acepta alegremente. Un semáforo en rojo y verde a la vez no es solo un bug: es un peligro. Y, sin embargo, el tipo dice “este valor puede existir”.
La regla general: con n flags booleanos modelando estados mutuamente excluyentes, tienes 2^n estados representables pero solo n válidos (o n + 1 si “ninguno activo” es válido). Los 2^n - n valores restantes son sinsentidos que el compilador no puede detectar.
La solución cabe en una línea #
enum TrafficLight {
Red,
Yellow,
Green,
}Tres variantes. Tres estados representables. Tres estados válidos. La proporción es 1:1. Un semáforo que esté en rojo y verde a la vez literalmente no puede existir en memoria. Lo garantiza el compilador, no la disciplina del programador.
Segundo ejemplo: sesiones de usuario #
Considera un usuario que puede ser anónimo, estar logueado o estar baneado:
// El enfoque booleano
struct User {
is_logged_in: bool,
is_banned: bool,
}Cuatro combinaciones. ¿Qué significa is_logged_in: true, is_banned: true? ¿Puede un usuario baneado estar logueado? ¿Debería la app mostrar un dashboard o un aviso de baneo? Cada función que toque este struct debe responder esa pregunta, y puede responderla de forma distinta.
// El enfoque con enum
enum UserStatus {
Anonymous,
LoggedIn { username: String },
Banned { reason: String },
}Tres estados. Cada uno lleva solo los datos relevantes para él. No puedes acceder a username desde Banned porque ese campo no existe en esa variante. El tipo no solo documenta el invariante; lo hace cumplir.
Tercer ejemplo: pedidos online #
Un pedido online pasa por etapas: realizado, pagado, enviado, entregado, cancelado. El enfoque booleano:
struct Order {
is_paid: bool,
is_shipped: bool,
is_delivered: bool,
is_cancelled: bool,
}16 combinaciones (2^4). Solo 5 son válidas. ¿Puede un pedido estar entregado pero no enviado? ¿Cancelado pero también entregado? El tipo dice que sí. El dominio dice que no.
enum OrderStatus {
Placed,
Paid { transaction_id: String },
Shipped { tracking_number: String },
Delivered { delivered_at: DateTime },
Cancelled { reason: String },
}Cinco variantes. Cinco estados. Cada uno lleva sus propios datos. Un pedido cancelado no tiene numero de seguimiento. Un pedido recien hecho no tiene ID de transaccion. Las combinaciones imposibles han desaparecido.
Por qué esto no es solo cuestión de estilo #
Hay una distinción entre dos propiedades de un tipo:
- Completitud representacional: todo estado válido del dominio puede codificarse. Tanto los booleanos como el enum lo consiguen.
- Corrección semántica: solo estados válidos del dominio pueden codificarse. Solo el enum lo consigue.
Un producto de flags booleanos es representacionalmente completo pero semánticamente laxo. Admite valores que compilan pero no tienen significado de dominio. Cada uno de esos valores es un bug potencial esperando a que un flujo de código lo produzca.
Cada función que recibe el struct booleano debe hacer una de estas dos cosas:
- Ignorar los estados imposibles y confiar en que ningún flujo de código los produzca (hasta que lo hace, durante un refactor, a las 2 AM en producción).
- Comprobar defensivamente con aserciones como
assert!(!(is_paid && is_cancelled)), llenando el código de guardas contra estados que el sistema de tipos debería haber prevenido.
Ninguna opción es buena. La causa raíz es que el tipo es demasiado permisivo: puede representar valores que no tienen significado en el dominio.
Parsea, no valides #
Alexis King, en Parse, don’t validate, lo resume muy bien:
“La diferencia entre validar y parsear reside casi enteramente en cómo se preserva la información.”
Cuando validas con flags booleanos, el conocimiento (“este pedido está en estado Enviado”) vive en un valor en tiempo de ejecución sobre el que el sistema de tipos no puede razonar. Cuando parseas a un enum, el conocimiento se codifica en el tipo, y el compilador puede razonar sobre él estáticamente.
Un flag booleano es una pregunta que sigues haciendo. Una variante de enum es una respuesta que obtienes una vez.
Qué viene después #
En la Hacer los estados invalidos irrepresentables 2. El algebra detras de tus tipos, veremos por qué esto funciona desde una perspectiva matemática: tipos de datos algebraicos, la aritmética de la cardinalidad, y cómo el mismo principio se aplica en Rust, TypeScript, Java, Scala, Haskell y OCaml.
En la Hacer los estados invalidos irrepresentables 3. Bugs reales por estados representables sin sentido, examinaremos bugs reales causados por estados representables sin sentido: el error del billon de dolares de Tony Hoare, la epidemia de gestion de estado en UI, y pesadillas de procesamiento de pagos.