En la Hacer los estados invalidos irrepresentables 1. Por que los flags booleanos son bugs disfrazados vimos por qué los flags booleanos son bugs disfrazados. En la Hacer los estados invalidos irrepresentables 2. El algebra detras de tus tipos aprendimos el álgebra que lo explica. Ahora veamos el daño real que esto causa.
Estos no son escenarios hipotéticos. Son patrones que han causado caídas de servicio, vulnerabilidades de seguridad y pérdidas financieras en toda la industria.
El error del billón de dólares de Tony Hoare #
El estado inválido más famoso que debería haber sido irrepresentable: null.
Tony Hoare, el inventor de las referencias nulas, lo llamó su “error del billón de dólares”. En sus propias palabras:
“Lo llamo mi error del billón de dólares. Fue la invención de la referencia nula en 1965. […] Esto ha llevado a innumerables errores, vulnerabilidades y caídas de sistema, que probablemente han causado mil millones de dólares de dolor y daño en los últimos cuarenta años.”
El problema es estructural. En lenguajes como Java, C# o JavaScript, todo tipo de referencia incluye implícitamente null en su dominio. Un String no es realmente un string: es un String | null. Toda función que recibe un String debe considerar la posibilidad de que en realidad sea nada.
La solución es el caso más simple de hacer los estados inválidos irrepresentables:
// En vez de un string nullable:
let name: String = get_name(); // podría ser null en otros lenguajes
// Usa Option para hacer la ausencia explícita:
let name: Option<String> = get_name();
match name {
Some(n) => println!("Hola, {n}"),
None => println!("No se proporciono nombre"),
}Option<String> te obliga a manejar ambos casos. No puedes llamar a .len() sobre un Option<String> sin antes demostrar que es Some. Lo comprueba el compilador, no la memoria del programador.
La epidemia de gestión de estado en UI #
Este patrón está en todas partes en aplicaciones frontend:
interface FetchState {
isLoading: boolean;
isError: boolean;
data: Data | null;
error: string | null;
}Cuatro campos. ¿Cuántas combinaciones? 2 * 2 * 2 * 2 = 16 (tratando Data | null y string | null como dos estados cada uno). ¿Cuántas tienen sentido?
| Estado | isLoading | isError | data | error | ¿Válido? |
|---|---|---|---|---|---|
| Idle | false | false | null | null | Si |
| Loading | true | false | null | null | Si |
| Success | false | false | Data | null | Si |
| Error | false | true | null | string | Si |
| ??? | true | true | Data | string | No |
| ??? | true | false | Data | null | No |
| …12 más | No |
Solo 4 estados son significativos. Los otros 12 son fantasmas. ¿Qué significa isLoading: true, isError: true, data: someValue? ¿Cargando con error pero también con datos? Cada componente que lee este estado tiene que decidir por sí mismo.
La solución:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };Cuatro variantes. Cuatro estados. Cada uno lleva solo sus datos relevantes. Un estado de carga no tiene mensaje de error. Un estado de éxito no tiene error. Las combinaciones imposibles han desaparecido.
Las aplicaciones React, Vue y Angular están llenas de bugs causados por componentes que renderizan en estados imposibles porque el tipo no los previene. Librerías como TanStack Query y SWR han migrado hacia patrones de unión discriminada precisamente por esto.
Shotgun parsing #
El paper de 2016 “The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them” define el shotgun parsing: un antipatrón de programación donde las comprobaciones de validación están dispersas por el código de procesamiento en vez de concentradas en la frontera de entrada.
La consecuencia: parte del input inválido se procesa antes de ser detectado, dejando el programa en un estado impredecible.
Esto es exactamente lo que ocurre con los flags booleanos. Cada función que lee los flags realiza su propia validación parcial ("¿es válida esta combinación?"), y si falta cualquier comprobación, el estado inválido fluye. La validación está desperdigada por todo el codebase en vez de manejarse una sola vez en la frontera.
Con un enum, la validación ocurre una sola vez: en el punto donde los datos entran al sistema y se parsean al tipo suma. Después, toda función aguas abajo trabaja con un tipo que solo puede representar estados válidos. Sin necesidad de shotgun parsing.
Procesamiento de pagos #
Un sistema de pagos usando booleanos:
struct Payment {
is_authorized: bool,
is_captured: bool,
is_refunded: bool,
is_voided: bool,
}16 combinaciones. La mayoría son puro sinsentido financiero:
is_captured: true, is_voided: true: ¿Capturado el dinero y anulada la transacción?is_refunded: true, is_captured: false: ¿Devuelto dinero que nunca se capturó?is_authorized: true, is_refunded: true, is_captured: false: ¿Autorizado y devuelto, pero nunca capturado?
Bugs en este patrón resultan en cobros dobles, devoluciones perdidas o entradas inconsistentes en el libro mayor. No es una preocupación teórica. Los bugs de procesamiento de pagos se miden en dólares.
La solución:
enum PaymentStatus {
Pending,
Authorized { auth_code: String, expires_at: DateTime },
Captured { auth_code: String, captured_at: DateTime },
Refunded { original_capture: String, refunded_at: DateTime },
Voided { reason: String, voided_at: DateTime },
}Cada estado lleva solo sus datos relevantes. No puedes tener un pago Refunded sin la referencia de captura original. No puedes tener un pago Captured sin un código de autorización. Las transiciones inválidas se convierten en errores de tipo.
Máquinas de estado codificadas en tipos #
El patrón más profundo detrás de todos estos ejemplos es que los estados del dominio forman una máquina de estados, y el tipo debería codificar esa máquina.
Cada variante de un tipo suma corresponde a un estado en un autómata finito. Los métodos que consumen una variante y producen otra son las transiciones:
impl Payment {
fn authorize(self, auth_code: String) -> Payment {
// Pending -> Authorized
}
fn capture(self) -> Payment {
// Authorized -> Captured
}
fn refund(self) -> Payment {
// Captured -> Refunded
}
fn void(self) -> Payment {
// Authorized -> Voided (no se puede anular despues de capturar)
}
}El sistema de tipos asegura que solo las transiciones válidas son expresables. No puedes llamar a refund sobre un pago Pending porque la firma del método lo previene. La máquina de estados no está documentada en una wiki o en un comentario de código; está codificada en el tipo.
Esto es exactamente lo que la verificación formal hace con model checkers: enumerar todos los estados alcanzables y verificar que los estados inválidos son inalcanzables. Con tipos de datos algebraicos, el “model checker” es el compilador, y se ejecuta en cada build.
La conclusión #
Hacer los estados inválidos irrepresentables no es un consejo de estilo de código. Es un principio de diseño enraizado en la teoría de tipos, conectado con los métodos formales, y validado por décadas de bugs del mundo real:
- Las referencias nulas son un estado inválido (
Stringque no es un string) que debería haber sidoOption<String>. - Los estados de carga en UI con flags booleanos crean estados fantasma que causan bugs de renderizado.
- El shotgun parsing dispersa la validación por todo el codebase en vez de concentrarla en la frontera.
- Las maquinas de estado de pagos con flags booleanos crean sinsentidos financieros que cuestan dinero real.
La solución es siempre la misma: reemplaza el producto de booleanos por un tipo suma. Deja que el compilador haga cumplir los invariantes de tu dominio. Diseña tus tipos de modo que lo imposible sea inexpresable.
Eso es lo que Minsky quería decir. No “los enums molan”, sino: diseña tus tipos de modo que lo imposible sea inexpresable.
Si te interesa el fundamento teórico detrás de este principio, cómo los tipos se relacionan con proposiciones lógicas y por qué un programa bien tipado es literalmente una demostración, echa un vistazo a La correspondencia Curry-Howard. Cuando los tipos se convierten en demostraciones.