wandres.dev
MANEJO DE ERRORES · Result, ?, panic

? más allá de Result: Option, main y closures

El operador ? es polimórfico sobre el trait Try: funciona sobre Option con la misma simetría que sobre Result, aunque los dos mundos no se mezclan sin un puente explícito. Cómo main devuelve Result gracias al trait Termination, y la trampa clásica del ? dentro de un closure, que retorna del closure y no de la función que lo rodea, con el patrón collect para recuperar el cortocircuito.

⏱ 18 min

El operador ? no es una característica de Result: es polimórfico sobre el trait Try, y eso abre tres fronteras que separan a quien lo usa por imitación de quien lo entiende. La primera es Option, donde ? propaga la ausencia con la misma elegancia con que propaga el error —pero sin mezclar los dos mundos sin permiso—. La segunda es main, que puede devolver Result y usar ? en la cima del programa gracias a un trait discreto llamado Termination. La tercera es la más traicionera: ? dentro de un closure, donde retorna del closure y no de la función que lo rodea, una trampa que solo se domina entendiendo que ? es, en el fondo, un return.

🎯 Al terminar esta lección sabrás
  • Usar ? sobre Option y entender por qué Option y Result no se mezclan sin un puente.
  • Devolver Result desde main y comprender el trait Termination y el código de salida.
  • Reconocer la trampa del ? dentro de un closure y su semántica de return local.
  • Recuperar el cortocircuito en iteradores con collect, try_fold y afines.

? sobre Option, y por qué los mundos no se mezclan

? funciona sobre Option con la simetría que ya esperas: si es Some(v) se evalúa a v; si es None, ejecuta return None. La única condición es que la función retorne Option.

fn iniciales(nombre: &str, apellido: &str) -> Option<String> {
    let a = nombre.chars().next()?;    // si esta vacio, return None
    let b = apellido.chars().next()?;
    Some(format!("{a}.{b}."))
}

En términos del trait Try, el residuo de Option es Option<Infallible>: None no lleva causa, solo señala ausencia. De ahí una restricción de tipos importante: no puedes usar ? sobre un Option dentro de una función que devuelve Result, ni al revés, porque los residuos no coinciden y None no tiene un error que convertir. El puente es explícito y lo eliges tú, dándole significado a la ausencia:

fn puerto(cfg: &Config) -> Result<u16, MiError> {
    let bruto = cfg.get("port").ok_or(MiError::FaltaPuerto)?;   // Option -> Result
    let n = bruto.parse()?;                                     // ya en el mundo Result
    Ok(n)
}

ok_or (o ok_or_else para construir el error de forma perezosa) convierte un None en el Err que tú decidas; en sentido inverso, .ok() descarta el error de un Result para volver a Option. La costura es deliberada: pasar de “no hay valor” a “falló por esta razón” es una decisión de diseño, no una coerción automática.

main que devuelve Result: el trait Termination

main puede devolver Result, lo que habilita ? en la cima del programa:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let contenido = std::fs::read_to_string("config.toml")?;
    println!("{contenido}");
    Ok(())
}

Esto no es un caso especial cableado: main puede devolver cualquier tipo que implemente el trait Termination, cuyo método produce un ExitCode. La biblioteca estándar lo implementa para (), para Result<T, E> con E: Debug, para Infallible y para ExitCode. Cuando main devuelve Ok(()), el proceso sale con código de éxito; cuando devuelve Err(e), el runtime imprime Error: {e:?} (nota: usa Debug, no Display) en stderr y sale con código de fallo. Para controlar el código exacto, devuelve un ExitCode:

use std::process::ExitCode;

fn main() -> ExitCode {
    if entorno_ok() { ExitCode::SUCCESS } else { ExitCode::from(2) }
}

Conviene contrastarlo con std::process::exit, que termina el proceso de inmediato sin ejecutar los destructores de la pila; devolver desde main es la salida ordenada, exit es la abrupta. Y esta misma maquinaria sirve en tests: una función de test puede devolver Result y usar ?, fallando limpiamente si algo devuelve Err.

flowchart TD
M[main devuelve un tipo Termination] --> R[Devuelve Ok o unit]
M --> E[Devuelve Err]
R --> C0[Proceso sale con codigo de exito]
E --> P[Runtime imprime Error con Debug en stderr]
P --> C1[Proceso sale con codigo de fallo]
style M fill:#cba6f7,color:#11111b
style R fill:#a6e3a1,color:#11111b
style E fill:#f38ba8,color:#11111b
style C1 fill:#89b4fa,color:#11111b

? dentro de closures e iteradores

Aquí está la trampa que atrapa a casi todo el mundo. Un closure tiene su propio tipo de retorno, así que un ? dentro de él retorna del closure, no de la función que lo rodea. Este código no propaga el error hacia procesar: intenta devolver un Err desde el closure de map, cuyo tipo de retorno no es Result, y no compila como se espera:

fn procesar(lineas: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    let nums = lineas.iter().map(|l| l.parse::<i32>()).collect();  // sin ? dentro
    nums
}

La solución idiomática no es meter ? en el closure, sino dejar que el closure produzca Result y recolectar hacia un Result. Esta es una de las conversiones más elegantes de la biblioteca: collect sobre un iterador de Result puede producir un Result<Vec<_>, _> que es Ok con todos los valores si todos fueron Ok, o el primer Err, cortocircuitando el resto:

fn procesar(lineas: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    lineas.iter().map(|l| l.parse::<i32>()).collect()   // Result<Vec<i32>, _>
}

El cortocircuito que ? da en código secuencial, collect::<Result<_, _>>() lo da en una tubería de iteradores. La misma transposición existe para Option. Cuando la lógica por elemento es más rica, try_fold y try_for_each propagan el primer fallo a través de un pliegue, y operaciones como sum o product sobre un iterador de Result devuelven Result. Para el caso general en que quieres ? con alcance local dentro de un bloque, existen los try blocks, aún inestables, que acotan el return del ? a un bloque en vez de a la función entera.

⚠️
Un ? en un closure no propaga hacia fuera: acota su alcance o usa collect

Si te encuentras escribiendo .map(|x| algo(x)?) y el compilador se queja, no fuerces el tipo: el ? está intentando retornar del closure. Dos salidas limpias: deja que el closure devuelva Result y cierra la tubería con .collect::<Result<Vec<_>, _>>(), o cambia a try_fold/try_for_each si necesitas acumular. El ? con alcance de bloque llegará con los try blocks estables.

? es polimórfico porque el fallo es una forma, no un tipo

Lo que estas tres fronteras revelan, juntas, es que ? nunca fue “el operador de Result”. Es el operador de una forma —la de un cómputo que o bien continúa con un valor o bien se corta con un residuo— y esa forma la captura el trait Try. Result la habita con su Err, Option con su None, ControlFlow con su Break; mañana un tipo tuyo podría habitarla. Por eso el mismo carácter propaga un error tipado, una ausencia sin causa, o un corte de un pliegue, sin que el compilador tenga un caso especial para cada uno: hay una sola abstracción y muchos habitantes. Que Option y Result no se mezclen sin ok_or no es una carencia, sino coherencia: sus residuos son distintos —uno lleva causa, el otro no— y forzar la mezcla sería inventar una causa que no existe, así que Rust te obliga a decidirla. Que main participe del juego vía Termination muestra que la abstracción llega hasta el borde mismo del programa, donde el Result se traduce en un código de salida del proceso. Y la trampa del closure, lejos de ser un defecto, es la prueba más limpia de que ? es un return: retorna de la función más cercana que lo encierra, y un closure es una función. Quien interioriza esto deja de memorizar reglas —“? va aquí sí y allá no”— y empieza a derivarlas de un solo principio: ? corta el cómputo actual y entrega su residuo a quien sepa reconstruirlo. Desde ese principio, ok_or, collect hacia Result, Termination y el alcance local del closure dejan de ser trucos sueltos y se vuelven consecuencias inevitables de una misma idea. Esa es la diferencia entre usar ? y entenderlo.

⚔️ Cruza las tres fronteras
  1. Escribe una función que devuelva Option y encadene dos ? sobre chars().next(); luego intenta usar uno de esos ? en una función que devuelve Result y lee el error del compilador.
  2. Tiende el puente con ok_or para convertir un None en un Err de dominio, y con .ok() en sentido inverso; explica por qué la conversión debe ser explícita.
  3. Convierte tu main en fn main() -> Result<(), Box<dyn std::error::Error>>, provoca un Err y observa que el mensaje impreso usa Debug; luego cámbialo a -> ExitCode y devuelve un código propio.
  4. Reproduce la trampa: intenta usar ? dentro de un .map(...) y arréglalo con .collect::<Result<Vec<_>, _>>(); demuestra que corta en el primer Err.
  5. Reescribe esa misma lógica con try_fold acumulando una suma, y razona en qué se diferencia del collect en cuanto a lo que construye.