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

match exhaustivo: extraer datos y blindar el futuro

match como la herramienta que abre un enum, extrae los datos de cada variante y obliga a cubrir todos los casos. Por qué la exhaustividad no es rigidez, sino un radar que convierte añadir una variante en una lista de tareas del compilador.

⏱ 16 min

Un enum guarda sus datos bajo llave: la x de un Move no existe hasta que confirmas que estás ante un Move. match es la llave. Y trae una exigencia que al principio irrita y luego adoras: tienes que contemplar todas las variantes. Esa obligación, la exhaustividad, es lo que transforma “añadir un caso nuevo” de una fuente silenciosa de bugs en una lista de errores de compilación que te lleva de la mano.

🎯 Al terminar esta lección sabrás
  • Usar match para extraer los datos de cada variante de un enum.
  • Entender la exhaustividad y por qué el compilador la impone.
  • Ver cómo añadir una variante convierte al compilador en tu lista de tareas.
  • Manejar _, if let, let ... else y #[non_exhaustive] con criterio.

Abrir el enum y sacar los datos

match compara un valor contra una serie de patrones y, al encajar uno, desestructura: liga los datos internos de la variante a nombres que puedes usar en esa rama. Es una expresión, no una sentencia, así que devuelve un valor:

fn describe(m: &Message) -> String {
    match m {
        Message::Quit => "cerrar".to_string(),
        Message::Move { x, y } => format!("mover a ({x}, {y})"),
        Message::Write(texto) => format!("escribir: {texto}"),
        Message::ChangeColor(r, g, b) => format!("color #{r:02x}{g:02x}{b:02x}"),
    }
}

En Message::Move { x, y }, los nombres x e y no vienen de fuera: los crea el patrón al abrir la variante, y solo viven dentro de esa rama. Cada brazo del match es la única región del programa donde los datos de esa variante son accesibles. El acceso y la comprobación de la variante son el mismo acto: no hay forma de leer la x sin haber demostrado antes que esto es un Move.

La exhaustividad no se negocia

Prueba a olvidar un caso. El compilador no lo perdona:

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        // falta Triangle
    }
}
error[E0004]: non-exhaustive patterns: `&Shape::Triangle { .. }` not covered

No es pedantería: es el corazón del asunto. Un match que no cubre todas las variantes es un programa que no ha decidido qué hacer en un caso que puede ocurrir. En otros lenguajes eso se cuela como un switch sin default, un if sin else, y explota en ejecución meses después. Rust convierte ese agujero en un error de compilación antes de que exista.

flowchart TD
A[Anado una variante al enum] --> B[Compilo]
B --> C{Todos los match la cubren}
C -->|no| D[Error E0004 por cada match incompleto]
D --> E[El compilador lista donde decidir]
E --> F[Reviso caso por caso]
C -->|si| G[Compila, ningun caso olvidado]
F --> G
style D fill:#f38ba8,color:#11111b
style G fill:#a6e3a1,color:#11111b

El compilador como lista de tareas

Aquí está el verdadero regalo de la exhaustividad, y solo se ve cuando el código evoluciona. Imagina que meses después el dominio crece y añades una variante:

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
    Ellipse { a: f64, b: f64 },   // nueva
}

En cuanto recompilas, todos los match sobre Shape que no contemplen Ellipse se encienden en rojo, uno por uno:

error[E0004]: non-exhaustive patterns: `&Shape::Ellipse { .. }` not covered
  --> src/geometria.rs:12:11
error[E0004]: non-exhaustive patterns: `&Shape::Ellipse { .. }` not covered
  --> src/render.rs:47:11
error[E0004]: non-exhaustive patterns: `&Shape::Ellipse { .. }` not covered
  --> src/serializar.rs:88:11

El compilador te entrega la lista exacta de sitios que tienes que revisar para que el cambio sea correcto. No hay que recordarlos, ni buscarlos con grep, ni rezar por la cobertura de tests. Refactorizar un tipo central deja de dar miedo porque el compilador te acompaña hasta el último rincón afectado.

⚠️
El comodín _ apaga ese radar

Es tentador cerrar un match con un _ => ... que capture todo lo demás. A veces es correcto, pero tiene un coste oculto: cuando añadas una variante nueva, el _ la absorbe en silencio y no recibirás el error que te avisaría de revisar ese sitio. Como norma, en un match sobre un enum tuyo enumera las variantes explícitamente; reserva el _ para tipos con demasiados casos (enteros, caracteres) o cuando de verdad quieras una política de “todo lo demás igual”. Un _ de más es un aviso futuro que apagas hoy.

Cuando solo te importa un caso

Un match completo es excesivo si solo actúas ante una variante. Para eso está if let, y su pareja let ... else para el camino feliz:

// if let: actua solo si encaja, con else opcional
if let Message::Write(texto) = &msg {
    println!("hay texto: {texto}");
}

// let-else: extrae o abandona (return, break, panic) en el camino de error
fn primer_char(m: &Message) -> char {
    let Message::Write(texto) = m else {
        return '?';   // no era Write: salimos ya
    };
    texto.chars().next().unwrap_or('?')  // aqui texto ya esta ligado
}

let ... else es especialmente valioso: extrae los datos al hilo principal de la función (sin anidar un bloque), y obliga a que la rama else diverja (return, break, panic!). Es el patrón idiomático para “dame el dato o lárgate”, y mantiene el código plano en lugar de una escalera de if let anidados.

match es una expresión, no una sentencia

En Rust match no ejecuta ramas: evalúa a un valor, y por eso puede estar a la derecha de un =. Todas las ramas deben producir el mismo tipo, y esa restricción se alía con la exhaustividad:

enum Semaforo { Rojo, Ambar, Verde }

fn instruccion(s: Semaforo) -> &'static str {
    let etiqueta = match s {
        Semaforo::Rojo => "detente",
        Semaforo::Ambar => "precaución",
        Semaforo::Verde => "avanza",
    };            // etiqueta: &str, decidido por el match completo
    etiqueta
}

Piensa en la consecuencia de diseño: como el match entero es una expresión de un solo tipo, no puedes “olvidar” devolver algo en una rama sin que el compilador lo note, ni dejar un caso sin cubrir sin que estalle E0004. Ser expresión y ser exhaustivo se refuerzan mutuamente: cubres todos los casos y cada uno produce el valor. Un if sin else no puede ser una expresión con valor; un match siempre lo es, precisamente porque siempre es total. Esta es la razón profunda de por qué en Rust idiomático casi no ves asignaciones a variables sin inicializar seguidas de un switch que las rellena: el match devuelve el valor de una vez.

ℹ️
El arma de doble filo del enum público

Si tu enum forma parte de una API pública y prevés añadir variantes en el futuro, márcalo #[non_exhaustive]. Eso obliga a quien lo consuma desde otra crate a incluir un brazo _, de modo que añadir una variante no rompa su compilación. Es un contrato explícito: “espera más casos”. Dentro de tu propia crate, en cambio, sigues obteniendo la exhaustividad total sobre él. Elegir entre marcarlo o no es elegir entre evolución sin romper a terceros y verificación máxima para ti.

La exhaustividad convierte el mantenimiento en un diálogo con el compilador

El valor de un match exhaustivo no se cobra el día que lo escribes: se cobra el día, meses después, en que alguien añade una variante. En un lenguaje sin esta garantía, ampliar un tipo suma es un acto de fe: modificas la definición y luego esperas haber encontrado a mano todos los switch, visitor e if que había que tocar; los que se te escapan no fallan al compilar, fallan en producción, en el caso raro, en el peor momento. Rust invierte esa relación. La definición del tipo y su uso quedan atados por el verificador: no puedes cambiar el uno sin que el otro te reclame atención. Añadir una variante deja de ser una apuesta y pasa a ser un diálogo: cambias el enum, compilas, y el compilador te devuelve la lista precisa de decisiones pendientes, ni una de más ni una de menos. Esto reescribe qué significa “código mantenible”: no es el que está bien documentado, sino aquel cuyos invariantes el compilador se niega a dejarte romper. La exhaustividad es la máquina que teje esa red bajo cada tipo suma que defines, y por eso resistir la tentación del _ es una de las disciplinas que más bugs te ahorrará en toda tu vida como programador de Rust.

⚔️ Deja que el compilador te guíe
  1. Escribe area para Shape con sus tres variantes y comprueba que compila.
  2. Añade Ellipse { a, b } al enum y observa el error E0004; corrígelo añadiendo la rama.
  3. Reescribe area cerrando con _ => 0.0, vuelve a añadir otra variante y comprueba que ya no te avisa: entiende el peligro que acabas de introducir.
  4. Convierte un if let ... else anidado en un let ... else plano y compara la legibilidad.
  5. Investiga #[non_exhaustive]: márcalo en un enum público y razona por qué obliga a los consumidores de otra crate a incluir un _.