wandres.dev
OPTION Y RESULT · sin null ni excepciones

El operador ?: propagar errores con limpieza

El operador ? colapsa el match de propagación en un solo carácter: desenvuelve el Ok o el Some, o hace return temprano del Err o None. La conversión con From, el uso en main, y por qué Option y Result son la base del estilo de Rust.

⏱ 16 min

Ya sabes construir, transformar y desenvolver Option y Result. Falta el gesto más frecuente de todos: cuando una función llama a otra falible y quiere propagar el fallo hacia arriba en lugar de manejarlo aquí. Hacerlo a mano con match es tan repetitivo que ensucia toda función no trivial. El operador ? colapsa ese patrón en un solo carácter, y al hacerlo revela por qué Option y Result no son dos tipos más de la biblioteca, sino la gramática misma sobre la que se escribe Rust.

🎯 Al terminar esta lección sabrás
  • Reconocer el match de propagación repetitivo que ? viene a eliminar.
  • Usar ? para desenvolver o hacer return temprano sobre Result y sobre Option.
  • Entender la conversión automática del error con el trait From, semilla del nivel 23.
  • Ver por qué Option y Result son la base del estilo idiomático de Rust.

Propagar a mano: el ruido del match

Considera una función que lee un fichero, lo recorta y lo devuelve. Cada paso puede fallar, y si falla, queremos devolver ese error al llamador. Escrito a mano:

fn leer_config() -> Result<String, std::io::Error> {
    let contenido = match std::fs::read_to_string("config.toml") {
        Ok(c) => c,
        Err(e) => return Err(e),   // propagamos el error tal cual
    };
    Ok(contenido.trim().to_string())
}

Ese match no aporta ninguna lógica: solo dice “si va bien, sigue con el valor; si va mal, devuélvelo ya”. Es puro ceremonial, y en una función con cinco pasos falibles se multiplica hasta enterrar lo que de verdad hace el código.

El operador ?: la propagación como puntuación

El operador ?, colocado tras una expresión que devuelve Result, hace exactamente eso: si es Ok(v), se evalúa a v y el programa sigue; si es Err(e), ejecuta return Err(e) inmediatamente. La misma función, con ?:

fn leer_config() -> Result<String, std::io::Error> {
    let contenido = std::fs::read_to_string("config.toml")?;   // desenvuelve o propaga
    Ok(contenido.trim().to_string())
}

Una línea, una intención. El ? desaparece visualmente pero mantiene el fallo completamente explícito: está ahí, en el carácter, y el tipo de retorno sigue siendo Result, así que el compilador te obliga a que la función entera sea honesta sobre su falibilidad. No es un try/catch disfrazado: es el cortocircuito de la vía ferroviaria de 7.4 elevado a sintaxis. Encadenar varios pasos se vuelve natural:

fn suma_del_fichero(ruta: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let texto = std::fs::read_to_string(ruta)?;   // io::Error si falla
    let mut total = 0;
    for linea in texto.lines() {
        total += linea.trim().parse::<i32>()?;     // ParseIntError si falla
    }
    Ok(total)
}

? sobre Option, y la conversión con From

El ? también funciona sobre Option, con la simetría que ya esperas: si es Some(v), se evalúa a v; si es None, hace return None. Eso sí, dentro de una función que devuelva Option (no puedes propagar un None desde una función que devuelve Result, ni al revés):

fn primera_letra_mayus(s: &str) -> Option<char> {
    let primera = s.chars().next()?;   // si la cadena está vacía, return None
    Some(primera.to_ascii_uppercase())
}

Sobre Result hay un poder extra decisivo. Cuando propagas un Err(e) de tipo F desde una función cuyo error es de tipo E, el ? convierte F en E automáticamente llamando a From::from. Por eso el ejemplo anterior funcionaba: io::Error y ParseIntError son distintos, pero ambos se convierten a Box<dyn Error>. Con un tipo de error propio, tú provees las conversiones:

enum MiError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl From<std::io::Error> for MiError {
    fn from(e: std::io::Error) -> Self { MiError::Io(e) }
}
impl From<std::num::ParseIntError> for MiError {
    fn from(e: std::num::ParseIntError) -> Self { MiError::Parse(e) }
}

fn cargar(ruta: &str) -> Result<i32, MiError> {
    let texto = std::fs::read_to_string(ruta)?;   // io::Error → MiError vía From
    let n: i32 = texto.trim().parse()?;           // ParseIntError → MiError vía From
    Ok(n)
}
flowchart TD
A[expresion seguida de interrogacion] --> B[Es Ok o Some]
A --> C[Es Err o None]
B --> D[Se desenvuelve y el programa sigue]
C --> E[return temprano con el error convertido por From]
E --> F[El llamador recibe el fallo tipado]
style B fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b
style D fill:#89b4fa,color:#11111b
style F fill:#cba6f7,color:#11111b

Incluso main puede devolver Result, lo que permite usar ? en el nivel más alto de un programa:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let contenido = std::fs::read_to_string("config.toml")?;
    println!("{contenido}");
    Ok(())   // si algo falla, main devuelve Err y el proceso sale con código ≠ 0
}
ℹ️
? no traga el error: lo hace fluir

Es tentador leer ? como “ignora el error y sigue”. Es justo lo contrario. ? propaga el error intacto (convertido, pero no perdido) hacia el llamador, que ahora es responsable de él. La honestidad se conserva en toda la cadena: cada función de la pila declara en su firma que puede fallar, y el fallo asciende explícito hasta que alguien decide manejarlo con match o los combinadores de 7.4. ? cambia dónde se maneja el error, nunca si se maneja.

Option y Result como la base del estilo de Rust

Da un paso atrás y mira el nivel entero. Sin excepciones y sin null, Rust necesitaba una forma de expresar “esto podría no dar valor” y “esto podría fallar” que fuera ordinaria: un valor con tipo, visible en las firmas, verificado por el compilador. Esa forma son Option y Result. Y sobre esos dos tipos se levanta todo lo demás: los combinadores de 7.4 los transforman en tuberías, el ? los propaga sin ruido, y el #[must_use] impide olvidarlos. No es una biblioteca de manejo de errores pegada al lenguaje: es el estilo del lenguaje. Cuando leas código Rust idiomático, verás esta gramática por todas partes, y escribirla con fluidez es lo que separa traducir otro lenguaje a Rust de pensar en Rust.

? es la puntuación de la falibilidad: hace legible el camino feliz sin ocultar el fallo

El operador ? parece un truco de comodidad, pero es la clave de bóveda de todo el diseño. Piensa en la tensión que resuelve. Los lenguajes con excepciones logran un camino feliz legible —el código no se llena de comprobaciones— pero al precio de volver el fallo invisible: no aparece en las firmas, es fácil de olvidar, salta por canales ocultos. Los lenguajes que devuelven códigos de error (el C clásico, el Go con su if err != nil) hacen el fallo explícito, pero al precio de sepultar la lógica bajo comprobaciones repetidas en cada línea. Durante décadas pareció que había que elegir: o legibilidad, o honestidad. ? disuelve el dilema. El fallo es un valor —explícito, tipado, imposible de ignorar por descuido gracias a #[must_use]— y a la vez su propagación es un solo carácter que no rompe la lectura del camino feliz. Consigues las dos cosas que parecían incompatibles. Y no es un añadido especial: ? es azúcar sobre match y From, que a su vez operan sobre Option y Result, que a su vez no son más que enum con la optimización de nicho de 6.1. Todo el edificio del manejo de errores de Rust —hasta el thiserror y el anyhow del nivel 23— descansa sobre esta base minúscula y coherente: tipos suma, exhaustividad, valores en vez de excepciones. Dominar este nivel no es aprender dos tipos; es haber entendido la columna vertebral sobre la que se sostiene, en pie, el resto del lenguaje.

⚔️ Propaga con una sola marca
  1. Escribe leer_config con el match manual y luego con ?; compara línea a línea qué desapareció y qué se conservó.
  2. Escribe una función que devuelva Option<char> y use ? sobre chars().next(); compruébala con una cadena vacía y una con contenido.
  3. Define el enum MiError con dos variantes y sus impl From, y escribe cargar usando ? dos veces sobre errores de tipos distintos.
  4. Convierte tu main en fn main() -> Result<(), Box<dyn std::error::Error>> y usa ? para leer un fichero; borra el fichero y observa el código de salida del proceso.
  5. Intenta usar ? sobre un Option dentro de una función que devuelve Result y lee el error del compilador; explica por qué los mundos no se mezclan sin una conversión explícita (ok_or de 7.4).