wandres.dev
OPTION Y RESULT · sin null ni excepciones

Result: el error como valor, no como excepción

Result modela el éxito con Ok o el fallo con Err como un valor ordinario que devuelve la función. Por qué Rust renuncia a las excepciones, qué gana con ello, y la frontera entre un error recuperable y un panic irrecuperable.

⏱ 15 min

Option responde “¿hay valor?”. Pero muchas operaciones no fallan en silencio: fallan por una razón —el fichero no existe, la cadena no era un número, la conexión se cayó— y esa razón hay que transportarla. Ahí entra Result<T, E>, el segundo pilar de esta pareja: un enum que contiene o bien el éxito o bien el error, como un valor cualquiera que la función te devuelve. Rust no tiene excepciones, y esta es la pieza que las sustituye. Entender por qué esa renuncia es una ventaja, y no una carencia, es entender media filosofía del lenguaje.

🎯 Al terminar esta lección sabrás
  • Leer Result<T, E> como el enum de dos variantes: Ok(T) y Err(E).
  • Entender por qué Rust no tiene excepciones y qué gana al no tenerlas.
  • Ver cómo #[must_use] impide que un error se tire a la basura en silencio.
  • Trazar la frontera entre un error recuperable (Result) y uno irrecuperable (panic!).

El éxito o el error, como valor

Como Option, Result es un tipo suma genérico, pero con dos parámetros: el tipo del éxito y el tipo del error.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Ok(valor) transporta el resultado bueno; Err(error) transporta la causa del fallo. Ambas variantes están en el preludio. Una función falible lo anuncia en su firma, y quien la llama ve que puede fallar sin leer una sola línea de su cuerpo:

fn dividir(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        return Err("división por cero".to_string());
    }
    Ok(a / b)
}

match dividir(10.0, 2.0) {
    Ok(r) => println!("resultado: {r}"),
    Err(e) => println!("error: {e}"),
}

Fíjate en lo que ocurre: el error no interrumpe el flujo, lo acompaña. Sale por el mismo return que el éxito, viaja por el mismo canal, y quien recibe el Result decide qué hacer con match, igual que con cualquier otro enum. Toda la biblioteca estándar que puede fallar habla este idioma: str::parse, std::fs::read_to_string, TcpStream::connect devuelven Result.

Por qué Rust no tiene excepciones

En los lenguajes con excepciones, el fallo viaja por un canal invisible. Una función marcada int leerEdad() puede lanzar una excepción sin que su firma lo diga; el control salta por encima de todo tu código hasta el catch más cercano, dondequiera que esté, y si no hay ninguno, el programa muere. Esto tiene tres costes que Rust no quiso pagar:

  • Invisibilidad. La firma miente por omisión: no sabes qué puede fallar sin leer la implementación entera y la de todo lo que llama.
  • Olvido fácil. Nada te obliga a capturar; olvidar un catch compila sin queja y explota en ejecución.
  • Desenrollado costoso. Propagar una excepción implica stack unwinding, un mecanismo pesado que ata de manos al optimizador.

Rust elige lo contrario: el fallo es un valor ordinario que aparece en el tipo de retorno. No hay canal oculto, no hay salto invisible, no hay nada que el compilador no pueda ver. La fiabilidad deja de depender de la disciplina del programador para recordar los catch y pasa a estar garantizada por el sistema de tipos.

flowchart TD
A[Operacion que puede fallar] --> B[Devuelve Result de T y E]
B --> OK[Ok con el valor bueno]
B --> ER[Err con la causa del fallo]
OK --> C[El llamador decide con match o el operador interrogacion]
ER --> C
C --> D[Ni canal oculto ni salto invisible]
style B fill:#cba6f7,color:#11111b
style OK fill:#a6e3a1,color:#11111b
style ER fill:#f38ba8,color:#11111b
style D fill:#89b4fa,color:#11111b

must_use: no puedes tirar un error a la basura

Un valor solo protege si no lo ignoras. Por eso Result lleva el atributo #[must_use]: descartar un Result sin mirarlo dispara un aviso del compilador.

fn guardar(datos: &str) -> Result<(), std::io::Error> {
    std::fs::write("salida.txt", datos)
}

fn main() {
    guardar("hola");   // aviso: unused `Result` that must be used
}
warning: unused `Result` that must be used
  = note: this `Result` may be an `Err` variant, which should be handled

Es la contrapartida al olvido fácil de las excepciones: aquí, ignorar un fallo es un acto explícito y ruidoso. Si de verdad quieres descartarlo, tienes que decirlo en voz alta con let _ = guardar("hola");, y ese _ deja constancia en el código de que la decisión fue deliberada.

Result frente a panic: recuperable vs irrecuperable

Rust separa los fallos en dos categorías y les da herramientas distintas. No confundirlas es clave de diseño:

  • Errores recuperables — cosas esperables del mundo real: un fichero que no está, una entrada del usuario mal formada, una conexión que se cae. El programa puede y debe reaccionar. Se modelan con Result, y quien llama decide.
  • Errores irrecuperablesbugs: un índice fuera de rango, una invariante rota, un estado que jamás debería darse. No hay reacción sensata salvo abortar. Se expresan con panic!, que desenrolla la pila y termina el hilo.
// Recuperable: el llamador se las verá con el Err.
fn leer_puerto(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.trim().parse()
}

// Irrecuperable: si esto pasa, el programa está roto; no hay plan B.
fn tercero(v: &[i32]) -> i32 {
    assert!(v.len() >= 3, "se esperaban al menos 3 elementos");
    v[2]   // indexar fuera de rango también sería un panic
}

La prueba del algodón: pregúntate “¿tiene sentido que quien me llama intente recuperarse de esto?”. Si la respuesta es sí, devuelve Result. Si la respuesta es “no, esto es un bug y quiero que se note ya”, usa panic!. Un parse que falla es un Result; un índice negativo calculado por tu propia lógica es un panic!.

Convertir el error en valor es sacar el fallo de las sombras

La decisión de Rust de no tener excepciones parece técnica, pero es filosófica. Una excepción trata el fallo como algo excepcional, una anomalía que se saca del flujo normal y se manda por un tubo aparte, invisible en las firmas y fácil de olvidar. Rust rechaza esa premisa: en el software real, fallar es parte del flujo normal. Los ficheros no están, las redes se caen, los usuarios teclean basura. Si el fallo es tan ordinario como el éxito, debe viajar por el mismo canal que el éxito —ser un valor, tener un tipo, aparecer en la firma— para que el compilador pueda razonar sobre él con las mismas armas. La consecuencia es enorme: el manejo de errores deja de ser un mecanismo de control de flujo aparte y se vuelve código normal, componible con las mismas herramientas que todo lo demás —match, los combinadores de 7.4, el operador ? de 7.5—. No hay una segunda gramática que aprender para “cuando las cosas van mal”. Y como Result es #[must_use], la vía por defecto no es el olvido silencioso, sino el reconocimiento explícito. Cuando el error es un valor, no hay dónde esconderlo: o lo manejas, o el compilador te señala que estás mirando hacia otro lado. Ese es el fundamento sobre el que 7.4 y 7.5 construyen la ergonomía; sin esta base, no serían posibles.

📝
El manejo de errores a fondo llega en el nivel 23

Aquí E es un simple String para no distraer. En la práctica se usan tipos de error propios (enums que implementan el trait std::error::Error), Box<dyn Error>, y crates como thiserror y anyhow. Todo ese arte —tipos de error componibles, contexto, retrocompatibilidad— es el tema del nivel 23. Por ahora basta con dominar Result como valor.

⚔️ Modela el fallo con causa
  1. Escribe fn raiz(x: f64) -> Result<f64, String> que devuelva Err si x es negativo y Ok(x.sqrt()) en otro caso.
  2. Llámala en un match que imprima el resultado o el error.
  3. Provoca el aviso de #[must_use] ignorando un Result, y luego siléncialo correctamente con let _ = ...; razona por qué el _ explícito es mejor que borrar el aviso.
  4. Escribe dos versiones de “obtener el tercer elemento de un slice”: una que devuelva Result y otra que haga panic!. Argumenta en qué contexto usarías cada una.
  5. Para cada una de estas situaciones decide Result o panic! y justifícalo: usuario mete texto donde iba un número; tu función recibe un vector que garantizaste no vacío; se pierde la conexión a la base de datos.