Ir al contenido
  1. Posts/

Hacer los estados inválidos irrepresentables 2: el álgebra detrás de tus tipos

·7 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Hacer los estados invalidos irrepresentables - Este artículo es parte de una serie.
Parte 2: Este artículo

En la Hacer los estados invalidos irrepresentables 1. Por que los flags booleanos son bugs disfrazados vimos que los flags booleanos crean una explosión exponencial de estados representables, la mayoría de los cuales son sinsentidos. Los enums lo solucionan ofreciendo un mapeo 1:1 entre tipo y dominio.

Pero ¿por qué funciona? La respuesta está en el nombre: tipos de datos algebraicos. Los tipos obedecen aritmética, y la diferencia entre booleanos y enums es la diferencia entre multiplicar y sumar.

Tipos producto (AND): structs, tuplas, records
#

Un tipo producto combina multiples campos. Su cardinalidad (numero de valores posibles) es el producto de las cardinalidades de sus campos:

|A x B| = |A| * |B|

Un struct con dos campos bool tiene 2 * 2 = 4 valores posibles. Un struct con un bool y un enum de tres variantes tiene 2 * 3 = 6 valores.

Piénsalo como una cuadrícula. Un bool x bool es una cuadrícula 2x2 con 4 celdas. Cada celda es un valor representable, tenga sentido o no.

Tipos suma (OR): enums, uniones etiquetadas, variantes
#

Un tipo suma es una de varias alternativas. Su cardinalidad es la suma de las cardinalidades de sus variantes:

|A + B| = |A| + |B|

Un enum con tres variantes unitarias tiene 1 + 1 + 1 = 3 valores posibles. Un enum donde una variante lleva un String y otra no lleva nada tiene |String| + 1 valores.

Piénsalo como una lista. Eliges exactamente un elemento de la lista. Sin combinaciones, sin cuadrícula.

La aritmética en la práctica
#

Tipo Algebra Cardinalidad
bool 2
(bool, bool) 2 * 2 4
(bool, bool, bool) 2 * 2 * 2 8
(bool, bool, bool, bool) 2^4 16
enum { A, B, C } 1 + 1 + 1 3
enum { A, B, C, D, E } 1 + 1 + 1 + 1 + 1 5
Option<T> |T| + 1 |T| + 1
Result<T, E> |T| + |E| |T| + |E|
() (unit) 1
! (never) 0

La idea clave: Option<T> no es una funcionalidad especial del lenguaje. Es un tipo suma: Some(T) + None = |T| + 1. Result<T, E> también es un tipo suma: Ok(T) + Err(E) = |T| + |E|. Todo el modelo de manejo de errores de Rust son tipos de datos algebraicos hasta el fondo.

Multiplicación vs suma: una comparación concreta
#

Recuerda el pedido online de la Parte 1. Cuatro flags booleanos (is_paid, is_shipped, is_delivered, is_cancelled) crean un tipo producto:

Cardinalidad = 2 * 2 * 2 * 2 = 16 estados
Estados válidos = 5 (Placed, Paid, Shipped, Delivered, Cancelled)
Estados desperdiciados = 11

Un enum con cinco variantes crea un tipo suma:

Cardinalidad = 1 + 1 + 1 + 1 + 1 = 5 estados
Estados válidos = 5
Estados desperdiciados = 0

La diferencia entre 2^n y n no es estilo. Es la diferencia entre un tipo que miente sobre tu dominio y uno que dice la verdad.

Pattern matching exhaustivo: tu red de seguridad en compilación
#

Cuando haces match sobre un enum, el compilador realiza tres comprobaciones que reflejan técnicas de verificación formal:

  1. Comprobación de totalidad: toda variante posible está manejada. Ningún caso se olvida.
  2. Comprobación de inalcanzabilidad: si un brazo nunca puede coincidir, el compilador avisa. El código muerto se detecta estáticamente.
  3. Seguridad ante refactoring: añadir una nueva variante causa errores de compilación en cada match incompleto.

Compara el enfoque booleano con el enfoque enum:

// Booleanos: sin ayuda del compilador
if is_paid {
    // manejar pagado
} else if is_shipped {
    // manejar enviado
} else {
    // "realizado"... pero ¿qué pasa con cancelado? ¿entregado?
    // El compilador no dice nada cuando añades is_refunded.
}

// Enum: el compilador impone completitud
match status {
    OrderStatus::Placed => { /* ... */ }
    OrderStatus::Paid { .. } => { /* ... */ }
    OrderStatus::Shipped { .. } => { /* ... */ }
    OrderStatus::Delivered { .. } => { /* ... */ }
    OrderStatus::Cancelled { .. } => { /* ... */ }
    // Añade OrderStatus::Refunded y este match no compila.
}

El match no es azúcar sintáctico. Es una obligación de demostración: debes demostrar que tu código maneja cada estado posible. El compilador verifica la demostración en cada build.

El mismo principio en varios lenguajes
#

Hacer los estados inválidos irrepresentables no es una idea específica de Rust. Todo lenguaje con tipos suma lo soporta.

Haskell
#

data OrderStatus
  = Placed
  | Paid String        -- transaction id
  | Shipped String     -- tracking number
  | Delivered UTCTime
  | Cancelled String   -- reason

handleOrder :: OrderStatus -> IO ()
handleOrder Placed          = putStrLn "Esperando pago"
handleOrder (Paid txn)      = putStrLn ("Pagado: " ++ txn)
handleOrder (Shipped track) = putStrLn ("Enviado: " ++ track)
-- Faltan Delivered y Cancelled: warning de compilación con -Wall

Haskell tiene tipos suma desde 1990. El flag -fwarn-incomplete-patterns de GHC convierte los casos faltantes en warnings.

OCaml
#

type order_status =
  | Placed
  | Paid of string
  | Shipped of string
  | Delivered of float
  | Cancelled of string

let handle_order = function
  | Placed -> print_endline "Esperando pago"
  | Paid txn -> print_endline ("Pagado: " ^ txn)
  (* Faltan casos: Warning 8 *)

OCaml fue pionero en el pattern matching exhaustivo como error, no solo warning. Toda la infraestructura de trading de Jane Street se ejecuta sobre este principio.

TypeScript
#

type OrderStatus =
  | { kind: "placed" }
  | { kind: "paid"; transactionId: string }
  | { kind: "shipped"; trackingNumber: string }
  | { kind: "delivered"; deliveredAt: Date }
  | { kind: "cancelled"; reason: string };

function handleOrder(status: OrderStatus): string {
  switch (status.kind) {
    case "placed": return "Esperando pago";
    case "paid": return `Pagado: ${status.transactionId}`;
    case "shipped": return `Enviado: ${status.trackingNumber}`;
    case "delivered": return `Entregado el ${status.deliveredAt}`;
    case "cancelled": return `Cancelado: ${status.reason}`;
    default: {
      const _exhaustive: never = status;
      return _exhaustive; // Error de compilacion si falta un caso
    }
  }
}

El truco de never en TypeScript proporciona comprobación exhaustiva. El campo discriminante (kind) actúa como etiqueta. TypeScript estrecha el tipo en cada brazo, así que status.transactionId solo es accesible dentro de case "paid".

Java (17+)
#

sealed interface OrderStatus
    permits Placed, Paid, Shipped, Delivered, Cancelled {}

record Placed() implements OrderStatus {}
record Paid(String transactionId) implements OrderStatus {}
record Shipped(String trackingNumber) implements OrderStatus {}
record Delivered(Instant deliveredAt) implements OrderStatus {}
record Cancelled(String reason) implements OrderStatus {}

// Switch exhaustivo de Java 21:
String label = switch (status) {
    case Placed p -> "Esperando pago";
    case Paid p -> "Pagado: " + p.transactionId();
    case Shipped s -> "Enviado: " + s.trackingNumber();
    case Delivered d -> "Entregado el " + d.deliveredAt();
    case Cancelled c -> "Cancelado: " + c.reason();
    // Caso faltante: error de compilacion
};

Java llegó tarde a esta fiesta (sealed en Java 17, pattern matching switch en Java 21), pero el mecanismo es el mismo.

Scala
#

enum OrderStatus:
  case Placed
  case Paid(transactionId: String)
  case Shipped(trackingNumber: String)
  case Delivered(deliveredAt: Instant)
  case Cancelled(reason: String)

def handleOrder(status: OrderStatus): String = status match
  case OrderStatus.Placed              => "Esperando pago"
  case OrderStatus.Paid(txn)           => s"Pagado: $txn"
  case OrderStatus.Shipped(track)      => s"Enviado: $track"
  case OrderStatus.Delivered(at)       => s"Entregado el $at"
  case OrderStatus.Cancelled(reason)   => s"Cancelado: $reason"

El enum de Scala 3 proporciona tipos suma de primera clase con matching exhaustivo. En Scala 2, sealed trait + case class conseguían el mismo efecto.

La conclusión
#

La aritmética es simple: los tipos producto multiplican, los tipos suma suman. Cuando tu dominio tiene k estados mutuamente excluyentes y los modelas como n flags booleanos, representas 2^n estados de los cuales solo k son válidos. Los 2^n - k valores restantes son bugs esperando su momento.

La solución también es simple: usa un tipo suma. En Rust, es un enum. En Haskell, una declaración data. En TypeScript, una unión discriminada. En Java, una interfaz sellada. En Scala, un enum o sealed trait. La sintaxis cambia; el principio no.

En la Hacer los estados invalidos irrepresentables 3. Bugs reales por estados representables sin sentido, veremos las consecuencias reales: los bugs que ocurren cuando los tipos mienten sobre tu dominio.

Hacer los estados invalidos irrepresentables - Este artículo es parte de una serie.
Parte 2: Este artículo