Enum y pattern matching en Rust

1. Descripción general

En muchos lenguajes, enum es solo una lista de constantes nombradas. En Rust, cada variante (variant) de un enum puede llevar sus propios datos. Cuando se combina con match, el compilador te obliga a manejar todos los casos posibles.

Este artículo va desde cómo definir enums, pasando por match, guards, binding con @, hasta Option, Result, if let, let else y matches!. Después de leerlo, podrás modelar estados de negocio usando enums y evitar errores comunes.

El artículo usa Rust 1.65 en adelante (necesario para let else) y no requiere librerías externas.

2. Definición de enum con datos

Supongamos que necesitas describir el estado de un pedido:

#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Pending,
    Paid(u64),
    Shipped { carrier: String, tracking: String },
    Cancelled { reason: String },
}

fn main() {
    let statuses = vec![
        OrderStatus::Pending,
        OrderStatus::Paid(250_000),
        OrderStatus::Shipped {
            carrier: "GHN".to_string(),
            tracking: "GHN123".to_string(),
        },
        OrderStatus::Cancelled { reason: "Agotado".to_string() },
    ];

    for s in &statuses {
        println!("{:?}", s);
    }
}

Este enum tiene tres tipos de variantes:

  • Pending es una unit variant, sin datos.
  • Paid(u64) es una tuple variant, que lleva la cantidad pagada por posición.
  • Shipped { .. } y Cancelled { .. } son struct variants, que llevan datos nombrados en fields.

#[derive(Debug, Clone, PartialEq)] permite imprimir valores con {:?}, clonarlos y compararlos con ==. La comparación será útil al escribir tests.

Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "Agotado" }

El punto clave: un pedido Pending no puede “accidentalmente” tener un código de seguimiento. Los datos solo existen en la variante que los necesita. Con un struct con muchos fields Option, no tendrías esta garantía.

3. match básico y exhaustividad

match compara un valor con cada patrón en orden de arriba a abajo. La rama que coincida primero se ejecutará:

fn describe(status: &OrderStatus) -> String {
    match status {
        OrderStatus::Pending => "Esperando pago".to_string(),
        OrderStatus::Paid(amount) => format!("Pagado {amount} VND"),
        OrderStatus::Shipped { carrier, tracking } => {
            format!("Enviado por {carrier}, código {tracking}")
        }
        OrderStatus::Cancelled { reason } => format!("Cancelado: {reason}"),
    }
}

Algunos puntos a notar:

  • status tiene tipo &OrderStatus. Gracias a los default binding modes, escribes patrones como si fuera un valor normal. Las variables amount, carrier, tracking serán referencias (&u64, &String), así que no hay datos que se muevan.
  • match es una expresión. Todas las ramas deben devolver el mismo tipo, en este caso String.

Ahora intenta eliminar la rama Cancelled. El compilador se negará a compilar:

error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
 --> src/main.rs:12:11
   |
12 |     match status {
   |           ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered

Este es el mayor valor de match. Cuando añades una nueva variante, por ejemplo Refunded, todos los match que no la manejen reportan un error. No necesitas buscar manualmente dónde hay que hacer cambios.

4. Patrones avanzados: tuplas, |, guards, rangos y @

Los patrones no son solo para enums. Puedes hacer match de una tupla para verificar múltiples condiciones a la vez:

enum CustomerTier {
    Regular,
    Silver,
    Gold,
}

fn discount(tier: &CustomerTier, total: u64) -> u64 {
    match (tier, total) {
        (CustomerTier::Gold, _) => total / 10,
        (CustomerTier::Silver, t @ 1_000_000..) => t / 20,
        (CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
        _ => 0,
    }
}

fn main() {
    println!("{}", discount(&CustomerTier::Gold, 300_000));
    println!("{}", discount(&CustomerTier::Silver, 1_200_000));
    println!("{}", discount(&CustomerTier::Regular, 2_500_000));
    println!("{}", discount(&CustomerTier::Regular, 100_000));
}

Cada rama funciona así:

  • _ coincide con cualquier valor sin bind de variables.
  • t @ 1_000_000.. verifica que total esté en el rango de 1 millón o más, al mismo tiempo que vincula ese valor a t.
  • CustomerTier::Silver | CustomerTier::Regular usa | para combinar múltiples patrones en una rama.
  • if t >= 2_000_000 es un match guard. Es una condición adicional que solo se evalúa después de que el patrón coincida.
  • _ => 0 es la rama por defecto. Es obligatoria porque u64 tiene infinitos valores posibles.
30000
60000
50000
0

Con struct variants, usas .. para ignorar fields que no te interesan:

fn shipping_fee(status: &OrderStatus) -> u64 {
    match status {
        OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
        OrderStatus::Shipped { .. } => 30_000,
        _ => 0,
    }
}

Nota: El compilador no usa guards al verificar exhaustividad. Si solo escribes la rama Shipped { .. } if ... sin una rama Shipped sin guard, aún necesitas otra rama para cubrir todos los casos.

5. Option y Result son también enums

La librería estándar define Option<T> (con Some(T) y None) y Result<T, E> (con Ok(T) y Err(E)). Como son enums, los manejas con exactamente las mismas herramientas anteriores:

fn find_customer(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("An"),
        2 => Some("Bình"),
        _ => None,
    }
}

fn parse_quantity(input: &str) -> Result<u32, String> {
    match input.trim().parse::<u32>() {
        Ok(0) => Err("La cantidad debe ser mayor a 0".to_string()),
        Ok(n) => Ok(n),
        Err(e) => Err(format!("Inválido: {e}")),
    }
}

fn main() {
    println!("{:?}", find_customer(2));
    println!("{:?}", find_customer(9));
    println!("{:?}", parse_quantity(" 3 "));
    println!("{:?}", parse_quantity("0"));
    println!("{:?}", parse_quantity("abc"));
}

Ok(0) es un patrón anidado. Coincide cuando el resultado es Ok y el valor dentro es 0. Porque Ok(0) viene antes que Ok(n), el cero se atrapa antes de llegar a la rama general.

Some("Bình")
None
Ok(3)
Err("La cantidad debe ser mayor a 0")
Err("Inválido: invalid digit found in string")

Rust no tiene null. Todo valor “que podría faltar” debe ir a través de Option, y el compilador te obliga a manejar el caso None.

6. Sintaxis abreviada: if let, let else, while let, matches!

Escribir un match completo es verbose cuando solo te importa una variante. Rust tiene varias sintaxis más cortas para estos casos.

6.1. if let

fn tracking_code(status: &OrderStatus) -> Option<&str> {
    if let OrderStatus::Shipped { tracking, .. } = status {
        Some(tracking.as_str())
    } else {
        None
    }
}

if let ejecuta el bloque cuando el patrón coincide. La parte else es opcional. Esta sintaxis no te obliga a manejar todos los casos, así que solo úsala cuando las otras variantes realmente no importan.

6.2. let else

let else funciona bien con el patrón “early return”: obtén los datos si coinciden, si no, sal inmediatamente.

fn charge(status: &OrderStatus) -> Result<u64, String> {
    let OrderStatus::Paid(amount) = status else {
        return Err("Pedido no pagado".to_string());
    };
    Ok(*amount)
}

Después de la línea let, la variable amount está disponible en el resto de la función sin necesidad de anidar más bloques. El bloque else debe salir del flujo actual con return, break, continue o panic!.

6.3. while let y matches!

fn main() {
    let mut queue = vec![
        OrderStatus::Paid(100_000),
        OrderStatus::Pending,
        OrderStatus::Paid(400_000),
    ];

    let paid = queue
        .iter()
        .filter(|s| matches!(s, OrderStatus::Paid(_)))
        .count();
    println!("Pedidos pagados: {paid}");

    while let Some(status) = queue.pop() {
        println!("Procesando: {status:?}");
    }
}
  • matches!(valor, patrón) devuelve bool. Este macro es muy útil en filter o assert!.
  • while let Some(status) = queue.pop() itera hasta que pop() devuelva None.
Pedidos pagados: 2
Procesando: Paid(400000)
Procesando: Pending
Procesando: Paid(100000)

7. Métodos en enums: modelando transiciones de estado

Los enums también tienen impl como los structs. Un patrón útil es representar transiciones de estado válidas:

impl OrderStatus {
    fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
                carrier: carrier.to_string(),
                tracking: tracking.to_string(),
            }),
            other => Err(format!("No se puede enviar pedido en estado {other:?}")),
        }
    }

    fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
                reason: reason.to_string(),
            }),
            other => Err(format!("No se puede cancelar pedido en estado {other:?}")),
        }
    }

    fn is_final(&self) -> bool {
        matches!(self, OrderStatus::Cancelled { .. })
    }
}

Algunos puntos a notar:

  • ship y cancel toman self por valor, así que el estado anterior se consume. No puedes seguir usando la variable de estado anterior después de la transición.
  • La rama other => ... vincula todo el valor restante a la variable other para incluirlo en el mensaje de error.
  • is_final solo lee, así que toma &self.

8. Escribir tests para el comportamiento

Rust incluye un framework de testing. Colocas los tests en un módulo #[cfg(test)] en el mismo archivo:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paid_order_can_be_shipped() {
        let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");

        assert_eq!(
            result,
            Ok(OrderStatus::Shipped {
                carrier: "GHN".to_string(),
                tracking: "GHN123".to_string(),
            })
        );
    }

    #[test]
    fn pending_order_cannot_be_shipped() {
        let result = OrderStatus::Pending.ship("GHN", "X");

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Pending"));
    }

    #[test]
    fn cancelled_order_is_final() {
        let status = OrderStatus::Pending.cancel("Cliente cambió de idea").unwrap();

        assert!(status.is_final());
        assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "Cliente cambió de idea"));
    }
}

assert_eq! funciona porque el enum ya derivó PartialEq y Debug. Cuando un test falla, Rust imprime tanto el valor esperado como el real. En el último test, ref reason vincula el field por referencia, así status no se mueve dentro de matches!.

cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

9. Errores comunes y cuándo no usar enums

Abuso de _ pierde exhaustividad. Si escribes _ => ... para un enum que controlas, cuando añadas una nueva variante, el compilador se mantendrá en silencio. La nueva variante caerá directamente en la rama por defecto, que generalmente no es lo que quieres. Con enums de negocio, lista explícitamente cada variante.

Mover datos fuera de una referencia. Escribir match *status { OrderStatus::Cancelled { reason } => ... } causará un error:

error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference

Hay tres formas de arreglarlo: hacer match directamente en status (una referencia), usar ref reason, o clonar los datos si realmente necesitas poseerlos.

Poner la rama general antes de ramas específicas. Si Ok(n) viene antes que Ok(0), la rama Ok(0) nunca se ejecutará. El compilador solo advierte unreachable pattern, no es un error, así que fácilmente lo puedes pasar por alto.

Variante demasiado grande. El tamaño de un enum es el del variant más grande más el tag. Si un variant contiene un array grande, todos los valores del enum ocupan esa cantidad de memoria. Envuelve ese variant en Box<...>. El lint clippy::large_enum_variant te lo recordará.

Nota: Si escribes una librería y quieres añadir variantes después sin romper el código de usuarios, marca el enum con #[non_exhaustive]. Entonces, el código en otros crates debe tener una rama _.

¿Cuándo no usar enums? Los enums son mejores cuando el conjunto de casos es cerrado y lo controlas, como estados de pedidos o tipos de errores. Si el conjunto de tipos es abierto, como cuando usuarios de tu librería necesitan añadir nuevos métodos de pago, usa un trait (Box<dyn PaymentMethod> o genéricos). Añadir una variante requiere modificar el enum original, pero un trait permite extensión desde afuera.

10. Conclusión

Los enums en Rust permiten que cada caso lleve exactamente sus datos. match te obliga a manejar todos los casos en tiempo de compilación. Has visto:

  • Unit, tuple y struct variants.
  • match con binding, |, guards, rangos, @ y ...
  • Option y Result como enums normales.
  • Las sintaxis abreviadas if let, let else, while let, matches!.
  • Cómo usar métodos en enums para modelar transiciones de estado, con tests.

La regla más fácil de recordar: con enums que controlas, lista explícitamente todas las variantes en lugar de usar _. Entonces, cada vez que añadas una nueva variante, el compilador te señalará cada lugar que necesita cambios.

rustenumpattern matchingmatchoption