wandres.dev
ENUMS Y PATTERN MATCHING · el superpoder de Rust

Modelar el dominio con enums: estados inválidos irrepresentables

Enums como herramienta de diseño, no de sintaxis: máquinas de estados donde cada estado lleva solo sus datos, y árboles de sintaxis abstracta que se recorren solos. El principio de hacer que los estados imposibles no puedan ni escribirse.

⏱ 18 min

Un enum con datos no es una comodidad sintáctica: es una técnica de diseño. Cuando cada estado de tu sistema lleva exactamente los datos que ese estado necesita, y ningún otro, los estados imposibles dejan de poder escribirse. El bug no se detecta antes: sencillamente no cabe en el tipo. Este es el salto de “usar enums” a “diseñar con enums”, y es una de las ideas más poderosas que Rust toma de la programación funcional.

🎯 Al terminar esta lección sabrás
  • Aplicar el principio de hacer los estados inválidos irrepresentables.
  • Modelar una máquina de estados como un enum con datos por estado.
  • Construir y recorrer un árbol de sintaxis abstracta (AST) recursivo.
  • Distinguir cuándo un enum es diseño de dominio y no mera sintaxis.

El pecado del struct con campos opcionales

Mira cómo se modela típicamente una conexión de red en un lenguaje sin tipos suma ricos: un struct con un campo de estado y un montón de campos que a veces aplican:

// El anti-patrón: campos que solo valen en ciertos estados
struct Conexion {
    estado: String,              // "conectando" | "conectada" | "cerrada"
    socket: Option<Socket>,      // solo si conectada... ¿o conectando?
    desde: Option<Instant>,      // solo si conectada
    motivo_cierre: Option<String>, // solo si cerrada
}

Este tipo permite escribir tonterías: una conexión "cerrada" con un socket presente, una "conectada" sin desde, un motivo_cierre en una conexión que sigue viva. Cada combinación ilegal es un bug esperando su turno, y para evitarlos te llenas el código de comprobaciones defensivas y unwrap que rezan. El tipo miente: dice que hay 2⁴ combinaciones cuando el dominio real solo tiene tres estados.

La máquina de estados como enum

Un enum dice la verdad. Cada variante es un estado, y lleva solo los datos que ese estado posee. Los demás ni siquiera existen:

struct Socket;
use std::time::Instant;

enum Conexion {
    Conectando { intento: u8 },
    Conectada { socket: Socket, desde: Instant },
    Cerrada { motivo: String },
}

impl Conexion {
    // una transicion consume el estado y devuelve el siguiente
    fn confirmar(self, socket: Socket) -> Conexion {
        match self {
            Conexion::Conectando { .. } => Conexion::Conectada { socket, desde: Instant::now() },
            // desde cualquier otro estado, confirmar no tiene sentido: no transita
            otro => otro,
        }
    }
}

Ahora una Conectada siempre tiene socket y marca de tiempo; una Cerrada siempre tiene motivo y nunca socket. No hay Option, no hay defensivas: la ausencia de un campo en un estado se expresa por su ausencia en la variante. Y al hacer que las transiciones consuman self (lo mueven) y devuelvan el nuevo estado, el sistema de ownership garantiza que el estado viejo se destruye: es imposible seguir usando una Conexion que ya transitó.

stateDiagram-v2
[*] --> Conectando
Conectando --> Conectada: confirmar con socket
Conectando --> Cerrada: fallo o timeout
Conectada --> Cerrada: cerrar
Cerrada --> [*]
⚠️
El enum acota los estados; las transiciones, los caminos

Un enum de estados garantiza que cada estado es internamente coherente, pero por sí solo no impide una transición ilegal: nada te prohíbe escribir una función que convierta Cerrada en Conectada. Para blindar también las transiciones existe el patrón typestate, que codifica cada estado como un tipo distinto (Conexion<Conectada>) de modo que un método solo existe en el estado que lo permite. Es el siguiente peldaño; el enum es el fundamento imprescindible sobre el que se construye.

El AST: un enum que se recorre solo

El caso donde los tipos suma brillan más es el árbol de sintaxis abstracta. Una expresión aritmética es recursivamente “o un número, o una suma de dos expresiones, o un producto, o una negación”. Eso se traduce a un enum casi palabra por palabra:

enum Expr {
    Num(f64),
    Suma(Box<Expr>, Box<Expr>),
    Prod(Box<Expr>, Box<Expr>),
    Neg(Box<Expr>),
}

fn eval(e: &Expr) -> f64 {
    match e {
        Expr::Num(n) => *n,
        Expr::Suma(a, b) => eval(a) + eval(b),
        Expr::Prod(a, b) => eval(a) * eval(b),
        Expr::Neg(x) => -eval(x),
    }
}

fn main() {
    // representa  -(2 + 3) * 4
    let e = Expr::Prod(
        Box::new(Expr::Neg(Box::new(Expr::Suma(
            Box::new(Expr::Num(2.0)),
            Box::new(Expr::Num(3.0)),
        )))),
        Box::new(Expr::Num(4.0)),
    );
    assert_eq!(eval(&e), -20.0);
}

El Box es obligatorio: sin él, Expr se contendría a sí mismo y tendría tamaño infinito; Box mete la subexpresión en el heap y deja en el nodo solo un puntero de tamaño fijo. Fíjate en la estructura del eval: la recursión del tipo y la recursión de la función son la misma forma. El match exhaustivo garantiza que ningún tipo de nodo queda sin evaluar, y si mañana añades Expr::Div, el compilador te enviará directo a eval y a cualquier otro recorrido (un imprime, un deriva, un optimiza) a completar el caso. Un intérprete, un compilador, un motor de plantillas, un validador de reglas: todos son, en su núcleo, un enum recursivo y un puñado de match que lo recorren.

Del enum al typestate: estados que son tipos

El enum hace irrepresentables los estados incoherentes, pero por sí solo no impide llamar a un método en el estado equivocado. El siguiente peldaño codifica el estado en el tipo, no en una variante, usando un parámetro de tipo fantasma que no ocupa memoria:

use std::marker::PhantomData;

struct Abierta;
struct Cerrada;

struct Puerta<Estado> {
    _estado: PhantomData<Estado>,
}

impl Puerta<Cerrada> {
    fn abrir(self) -> Puerta<Abierta> { Puerta { _estado: PhantomData } }
}

impl Puerta<Abierta> {
    fn cerrar(self) -> Puerta<Cerrada> { Puerta { _estado: PhantomData } }
}

Ahora cerrar solo existe sobre una Puerta<Abierta>: intentar cerrar una puerta ya cerrada no es un error en ejecución, es código que no compila, porque el método ni siquiera está disponible en ese tipo. Donde el enum volvía irrepresentables los estados incoherentes, el typestate vuelve irrepresentables las transiciones ilegales. Son la misma filosofía —empujar el error al sistema de tipos— aplicada a dos ejes: el enum es el fundamento accesible que usarás el 90% de las veces; el typestate, el bisturí para las máquinas cuyo orden de operaciones debe garantizarse en compilación.

Hacer imposible el estado imposible es la forma más alta de corrección

Hay una jerarquía silenciosa en cómo un programa trata sus errores. En el peldaño más bajo, el error ocurre y nadie lo nota: datos corruptos que se propagan. Un peldaño arriba, el error ocurre y estalla: un panic, una excepción, un test en rojo. Más arriba, el error se detecta en compilación: no llega a ejecutarse. Pero existe un peldaño superior a todos, y es donde te coloca el diseño con enums: el error no puede ni escribirse. No es que el compilador cace el estado inválido; es que no hay ninguna combinación de tecleos que lo produzca, porque el tipo no le reserva ni un hueco en memoria. Una Conexion::Cerrada no tiene un socket que pueda estar mal: no tiene socket, punto. Un AST no puede representar una suma de un solo operando: la variante Suma exige dos. Cuando modelas así, categorías enteras de bug abandonan tu universo de lo posible, y con ellas se van las comprobaciones defensivas, los assert, los comentarios de “esto nunca debería pasar” que siempre pasan. Esto explica por qué elegir tus tipos es la decisión de ingeniería más apalancada que tomas: cada estado imposible que consigues volver irrepresentable es una clase infinita de tests que nunca tendrás que escribir, porque el teorema ya está demostrado en la forma del dato. Diseñar con enums no es sintaxis; es trasladar la corrección del terreno frágil de la vigilancia en tiempo de ejecución al terreno firme de lo que la máquina, por construcción, no puede expresar.

⚔️ Vuelve irrepresentable un estado inválido
  1. Toma el struct Conexion con cuatro Option y reescríbelo como enum de tres variantes; cuenta cuántas combinaciones ilegales acabas de eliminar.
  2. Añade a la máquina de estados una transición fallar(self) -> Conexion que lleve de Conectando a Cerrada con un motivo.
  3. Extiende Expr con Div(Box<Expr>, Box<Expr>), deja que el compilador te lleve a eval y decide qué hacer al dividir por cero (piensa en Result, que llega en el nivel 7).
  4. Escribe una función imprime(e: &Expr) -> String que reconstruya la expresión con paréntesis; observa cómo el match calca la estructura del tipo.
  5. Busca un struct de tu propio código con campos que solo valen “a veces” y bosqueja el enum que lo diría sin mentir.