Skip to content
← Back to explainers

Rust explainer

How the ? operator propagates failure

About 4 min to read. Allow extra time to work through examples.

Follow how ? extracts a successful value, returns early on failure, and converts compatible error types. Learn how closures and associated-type constraints affect the result.

This article uses Rust. You can make it your practice mode to browse related content across the site.

The ? operator lets a successful computation continue and propagates an early-exit value when it cannot. For everyday Result code, think of it as a short match with an error conversion.

Start with Result

For let value = operation()?;, the useful mental model is:

let value = match operation() {
    Ok(value) => value,
    Err(error) => return Err(From::from(error)),
};

The Ok case produces the inner value. The Err case returns from the enclosing function, converting the error into its return type. This is ordinary error propagation, not a panic or exception.

For example, a parser returning ParseIntError can be used inside a function returning MyError when MyError: From<ParseIntError>:

use std::num::ParseIntError;

#[derive(Debug)]
enum MyError {
    InvalidNumber(ParseIntError),
}

impl From<ParseIntError> for MyError {
    fn from(error: ParseIntError) -> Self {
        Self::InvalidNumber(error)
    }
}

fn parse_number(text: &str) -> Result<i32, MyError> {
    let number = text.parse::<i32>()?;
    Ok(number)
}

fn main() {
    assert_eq!(parse_number("42").unwrap(), 42);
    match parse_number("bad") {
        Err(MyError::InvalidNumber(error)) => {
            assert_eq!(error.kind(), &std::num::IntErrorKind::InvalidDigit);
        }
        Ok(_) => panic!("expected a parse failure"),
    }
}

Without the From implementation, this explicit parse produces a missing conversion error, typically E0277. When both error types already match, the standard identity implementation From<T> for T is sufficient.

Conversion is useful, but it is not the only purpose of ?: even without conversion, it removes repeated early-return matches.

Option needs an explicit bridge

With Option, Some(value)? produces the value and None? returns None. It does not automatically turn an error into an absent value or invent an error for you.

fn optional_number(text: &str) -> Option<i32> {
    let number = text.parse::<i32>().ok()?;
    Some(number)
}

fn required_first(values: &[i32]) -> Result<i32, &'static str> {
    let first = values.first().ok_or("empty input")?;
    Ok(*first)
}

fn main() {
    assert_eq!(optional_number("7"), Some(7));
    assert_eq!(optional_number("bad"), None);
    assert_eq!(required_first(&[9]), Ok(9));
    assert_eq!(required_first(&[]), Err("empty input"));
}

.ok() deliberately discards the parse error. .ok_or(...) supplies an error for an absent value; .ok_or_else(...) builds it only when needed. Choose the bridge according to whether the caller needs the failure detail.

Check the return boundary

An ordinary block does not create a new boundary for ?. A closure does, and an async block propagates into that block's future output rather than returning from the surrounding synchronous function.

This complete example uses one ? inside a closure and another after collection:

fn lookup(id: u32) -> Result<&'static str, &'static str> {
    match id {
        1 => Ok("Ada"),
        2 => Ok("Grace"),
        _ => Err("unknown id"),
    }
}

fn names(ids: &[u32]) -> Result<Vec<String>, &'static str> {
    let names = ids
        .iter()
        .map(|id| -> Result<String, &'static str> {
            Ok(lookup(*id)?.to_owned())
        })
        .collect::<Result<Vec<_>, _>>()?;
    Ok(names)
}

fn main() {
    assert_eq!(names(&[2, 1]).unwrap(), ["Grace", "Ada"]);
    assert_eq!(names(&[1, 99, 2]), Err("unknown id"));
    assert!(names(&[]).unwrap().is_empty());
}

A failed lookup returns from that closure invocation. Collecting into Result<Vec<_>, _> then stops at the first error, and the outer ? propagates it from names. Collecting into Vec<Result<_, _>> instead would retain a result for each input and continue iteration.

The trait model behind the operator

The following is conceptual pseudocode, not a stable Rust snippet to copy:

match Try::branch(expression) {
    ControlFlow::Continue(value) => value,
    ControlFlow::Break(residual) => {
        return FromResidual::from_residual(residual);
    }
}

Try::branch separates continuation from early exit. FromResidual builds the enclosing return value from the early-exit part, called a residual.

For Result<T, E>, the residual is Result<Infallible, E>. Infallible has no values, so a valid residual cannot contain an Ok value. For Option<T>, the residual is Option<Infallible>, whose only value is None.

These distinct residual types let the trait implementations distinguish the source of an early exit. Sharing an error payload does not by itself make two containers interchangeable. The provided Result conversion uses From<E>; Option has no error payload to convert.

Standard types such as ControlFlow also support ?. Using the operator is stable, but implementing the underlying Try and FromResidual traits for your own types requires unstable features on the stable toolchain used here. The trait model explains compatibility; it does not promise an exact branch count or machine-code sequence.

Conversion is different from type equality

A bound such as T: FromStr<Err = MyError> requires T's parser error to be exactly MyError. A From implementation does not make two types equal.

To accept convertible parser errors, use separate bounds and perform the conversion. With the MyError type and From implementation from the first example, this function accepts i32:

fn parse_converting<T>(text: &str) -> Result<T, MyError>
where
    T: std::str::FromStr,
    MyError: From<T::Err>,
{
    Ok(text.parse::<T>()?)
}

By contrast, a helper requiring FromStr<Err = MyError> rejects i32, whose parser error is ParseIntError. The From implementation cannot satisfy that equality constraint.

When diagnosing ?, check the operand type, the return boundary and its output type. Then check whether the diagnostic requires a conversion or an exact associated-type equality. Inferred types can affect which error appears first.

step 10 of 24

Errors Are Values

Up next Propagating with ?