Skip to content
← All articles

The ? operator, exactly

`?` is a `return` plus a `From::from`. Knowing the desugaring explains every error message you will ever get from it, including the confusing associated-type one.

? is the piece of Rust that most reliably gets described as magic, which is a shame, because it is about four lines of ordinary code and knowing those four lines explains every error message it will ever produce.

The first approximation

For a Result, this:

let n = might_fail()?;

means this:

let n = match might_fail() {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
};

Two things are happening, and beginners usually notice only the first.

  1. The success case unwraps. n is the value inside the Ok.
  2. The failure case returns — with a conversion. From::from(e) turns the error you have into the error your function returns.

That second half is the whole reason ? is worth having. Without it you would be writing map_err at every call site to reconcile error types. With it, you write one From impl per error pair and the plumbing disappears.

💡The desugaring says return Err(From::from(e)). If your function returns Result<T, MyError> and e is a ParseIntError, which From impl gets used — and what happens if there isn't one? click to reveal

The impl used is impl From<ParseIntError> for MyError, chosen by type inference from the return type of the enclosing function. If it does not exist, the code does not compile, and the diagnostic is E0277 with this note attached:

= note: the question mark operation (`?`) implicitly performs a conversion on
        the error value using the `From` trait

which is rustc telling you exactly which impl to go and write.

There is one impl you always get for free: impl<T> From<T> for T, the identity. That is why ? works with no effort at all when the error types already match — the conversion is still happening, it is just a no-op.

The real desugaring

The approximation above is what you should carry around day to day. The literal one, which is what the compiler actually does, is:

match Try::branch(expr) {
    ControlFlow::Continue(v) => v,
    ControlFlow::Break(r) => return FromResidual::from_residual(r),
}

Two traits. Try::branch splits a value into “carry on with this” or “stop with this”; FromResidual::from_residual builds the enclosing function’s return value out of the stopping part.

The word residual is the one worth pausing on. You might expect Try to say “my error type is E“. It does not. For Result<T, E>, the residual type is

Result<Infallible, E>

— a Result that cannot be Ok, because Infallible is a type with no values at all. So the thing that flows out of a ? is not a bare error; it is “a Result known to be in its Err arm”.

That indirection is not decoration. It is what keeps the type of the operand and the type of the enclosing function connected, so that ? on a ControlFlow cannot silently be used in a Result-returning function even though both go through the same two traits. Different residual, no impl, no compile.

💡Why go to the trouble of an uninhabited type? Wouldn't Residual = E have been simpler? click to reveal

It would have been simpler and it would have been wrong, for a reason that shows up the moment two Try types share an error type.

FromResidual is what decides which return types a given ? is legal in. If the residual for Result<T, E> were just E, then any type whose residual was also E would become interchangeable with it — the impl could not tell them apart. Wrapping it as Result<Infallible, E> stamps the source into the residual type, so impl<T, E, F: From<E>> FromResidual<Result<Infallible, E>> for Result<T, F> accepts residuals from Result and nothing else.

Option‘s residual is Option<Infallible> for the same reason. And because that type carries no payload, there is nothing to convert — which is precisely why ? on an Option does no From conversion at all.

The Infallible half is uninhabited so the compiler knows the Ok/Some arm is unreachable and optimises the whole thing away. At runtime ? is a branch, nothing more.

Where ? works

The Try implementations in std today:

  • Result<T, E>
  • Option<T>
  • ControlFlow<B, C>
  • Poll<Result<T, E>> and Poll<Option<Result<T, E>>> — the async ones, so ? works inside a poll function

You cannot add to that list. Try is still unstable on Rust 1.95 (try_trait_v2, tracking issue #84277), so you cannot implement it for your own types. If you have ever wondered why nobody’s crate lets you ? on their custom wrapper — that is why.

Two things it is not

? does not return from a block. It returns from the enclosing function or closure. This matters constantly:

let names: Vec<String> = ids
    .iter()
    .map(|id| lookup(*id)?.name.clone())   // returns from the CLOSURE
    .collect();

The ? there exits the closure, not the surrounding function, so the closure’s return type has to be a Result — and then collect has to deal with an iterator of results. If you wanted “abandon the whole function”, the ? has to be outside the closure.

This is also why try { … } blocks are a separate feature: a block that ? can return from is a genuinely different thing, and it is still experimental:

error[E0658]: `try` expression is experimental

? does not work in a const fn. Verified on 1.95:

error[E0658]: `?` is not allowed on `Option<i32>` in constant functions
  = note: see issue #143874

The traits it desugars to are not const, so there is nowhere for it to hang yet.

💡You are writing fn first_word_len(s: &str) -> Option<usize> and inside it you call s.parse::<i32>(), which returns a Result. Can you ? that? click to reveal

No — and the error says so directly:

error[E0277]: the `?` operator can only be used on `Option`s, not `Result`s,
              in a function that returns `Option`

The desugaring explains why. ? on a Result produces a residual of type Result<Infallible, ParseIntError>, and Option<usize> has no FromResidual<Result<Infallible, _>> impl. There is no automatic bridge, deliberately: turning a Result into an Option throws the error away, and Rust will not do that behind your back.

The fix is to throw it away explicitlys.parse::<i32>().ok()? — or, more often, to notice that your function should have been returning a Result in the first place.

Going the other way (a ? on an Option inside a Result function) has the mirror-image error, and its fix is .ok_or(…)? or .ok_or_else(…)?, because there you have to invent an error rather than discard one.

The error message that does not mention ? at all

This is the one that gets people stuck, so it is worth recognising on sight:

error[E0271]: type mismatch resolving `<i32 as FromStr>::Err == MyErr`
   |
   |     let n: i32 = s.parse()?;
   |                    ^^^^^ expected `MyErr`, found `ParseIntError`

Same cause as the friendly E0277 — a missing From impl — but phrased in terms of an associated type, because parse‘s error type is <i32 as FromStr>::Err rather than a concrete name. Nothing in the message says “question mark” or “From”.

Translate it mentally as: ? needed to convert ParseIntError into MyErr and could not.” Then go and write

impl From<ParseIntError> for MyErr { … }

and it goes away.

What to take from this

  • ? unwraps on success and returns with a From conversion on failure.
  • The conversion is the point. It is why error types compose across layers.
  • On Option there is no conversion — there is nothing to convert.
  • It exits the enclosing function or closure, never merely a block.
  • The Try machinery is public but unstable, so this is a mechanism you read rather than extend.

Everything about ? that looks like a special case turns out to be one of those five facts wearing a different error code.