Skip to content
intermediate Phase 7 · Error Handling with Result

The question mark Operator

Early-return Err values and convert error types through the From trait.

45m
0 problems
Topic Progress 0%

What ? Actually Desugars To, and Whose Return It Hijacks

Take a function that reads a file, parses it, and converts it to a struct — three steps, three chances to fail. Written by hand:

fn load(path: &Path) -> Result<Config, io::Error> {
    let text = match fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) => return Err(e),
    };
    let cfg = match parse(&text) {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    Ok(cfg)
}

The ? operator collapses it to two lines:

fn load(path: &Path) -> Result<Config, io::Error> {
    let text = fs::read_to_string(path)?;
    let cfg = parse(&text)?;
    Ok(cfg)
}

Its meaning is exactly this, and interviewers routinely ask you to say it out loud:

let text = match fs::read_to_string(path) {
    Ok(v)  => v,
    Err(e) => return Err(e.into()),   // early return from the FUNCTION
};

Two clauses matter. On Ok, ? unwraps the value and execution continues. On Err, it returns from the enclosing function — not from the current block, not from the loop, not from the async task's current await point. This is the most common misreading of the operator:

fn drain(conn: &mut Connection) -> Result<(), io::Error> {
    loop {
        let msg = conn.recv()?;   // returns from drain(), NOT just from the loop
        if msg.is_heartbeat() { continue; }
        process(msg);
    }
}

Beginners expect ? to try the next loop iteration; it does not. To keep going after a failure you need an explicit match or an if let Err(e) = ....

The second clause — e.into() — is the conversion the compiler inserts. ? does not pass the error through untouched; it applies From::from to it, so an error of type A becomes the function's declared error type B when impl From<A> for B exists. Since Rust has a blanket impl<T> From<T> for T, a same-type error passes through unchanged and you never notice the machinery. The moment the types differ, ? is doing real work: converting an io::Error into your own enum variant is a one-character difference between "compiles" and "mismatched types."

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = load(Path::new("app.toml"))?;   // io::Error -> Box<dyn Error> automatically
    println!("{cfg:?}");
    Ok(())
}

That main form is worth memorizing: when main returns Result<(), E: Debug> (or Box<dyn Error>), a returned Err is printed with Debug and the process exits with code 1 instead of 0 — a clean way to keep error handling at the top level without a single unwrap.

Finally, the constraint: only a function whose return type implements Try may contain ?. In practice that means Result<T, E>, Option<T>, Box<dyn Error>-returning Results, or the nightly Try trait. Put ? inside a function returning () and you get error[E0308]: the ? operator can only be used on Result and Option (or a type that implements std::ops::Try) plus a note that the function must return Result or Option. The fix is always the same: change the signature, or handle the error in place. Closures are the trap — |x| do_something(x)? will not infer a return type, so annotate it: |x| -> Result<T, MyError> { Ok(do_something(x)?) }.

Option gets the same treatment with a shorter desugaring: opt? is match opt { Some(v) => v, None => return None } — no conversion step, because there is no error payload to convert. An Option-returning function therefore reads like a chain of partial computations, which is the mental bridge to the Result version you already know.

The From Conversion: Why ? Changes Your Error Type on the Way Out

Now the half of ? that people learn the hard way. Given:

fn read_key(path: &Path) -> Result<Secret, io::Error> { ... }

fn load_secrets() -> Result<Vault, ConfigError> {
    let key = read_key(path)?;   // io::Error in, ConfigError out
    ...
}

read_key hands back an io::Error; load_secrets promises a ConfigError. The compiler makes it work by expanding ? to return Err(ConfigError::from(err)). So the real contract is:

? requires impl From<IncomingError> for OutgoingError. No such From impl, no compile.

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(toml::de::Error),
}

impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<toml::de::Error> for ConfigError {
    fn from(e: toml::de::Error) -> Self { ConfigError::Parse(e) }
}

Two lines per source error, and now every ? from fs and from the TOML parser works in the same function. thiserror later generates these From impls for you — see Implementing the Error Trait.

The conversion matrix is what interviewers actually probe:

Inner ? produces Function returns Works? Why
Result<T, A> Result<U, B> where B: From<A> Yes From::from inserted
Result<T, A> Result<T, A> Yes blanket impl<T> From<T> for T
Result<T, A> Option<T> No Option cannot be built from an A; no From between the two families
Option<T> Result<T, E> No None has no error to convert — supply one: opt.ok_or(Missing)?
Option<T> Option<T> Yes identity path

Bridging is your job: Result → Option with .ok(), Option → Result with .ok_or(err) or .ok_or_else(err_fn) (the lazy form when constructing the error costs work) — the same bridges introduced in Option Combinators. Once bridged, ? flows normally.

A pattern that combines both halves — conversion and collection — is worth having in muscle memory, because parsing a list is where ? earns its keep:

let ports: Vec<u16> = raw_lines
    .iter()
    .map(|l| l.trim().parse::<u16>())
    .collect::<Result<Vec<u16>, _>>()?;   // first bad line returns the whole function

Here ? does not touch the individual ParseIntErrors; collect has already fused the iterator of Results into a single Result<Vec<_>, _>, and ? propagates that one, converting it to the function's error type if needed. Notice what it does not do: it fails on the first bad line with no indication of which line. When the caller needs the index, you map it into your own error before collecting — .map(|(i, l)| l.parse().map_err(|e| ConfigError::BadLine { i, e })) — which is map_err from the next chapter doing its job one frame earlier.

One design consequence deserves attention: conversion erases where the error came from unless you keep the chain. If ConfigError::from(io_err) stores the io::Error inside a variant, source() can still walk back to it — that is the whole point of std::error::Error::source. If you instead stringify it (format!("{e}").into()), the original type is gone and no caller can downcast or inspect the OS error code. Convert into your typed enum; do not flatten it into a String on the way.

Box<dyn Error> is the escape hatch with different trade-offs: impl<E: Error + 'static> From<E> for Box<dyn Error> exists in the standard library, so ? boxes almost any error automatically with zero From work from you. You gain convenience and lose static typing — callers must downcast to distinguish "file missing" from "permission denied." In a binary that is usually fine; in a library, anyhow for Applications, thiserror for Libraries settles the choice.

The interview-ready phrasing: "? is an early return plus From::from on the error — it never changes control flow beyond returning from the enclosing function, and it never converts between Result and Option."

Where ? Alone Is Not Enough: Logging, Recovery, and Pre-Return Mapping

? handles the shape of failure propagation. It deliberately does not handle the content — and three everyday situations require you to drop back to an explicit match, or to decorate before the operator runs.

1. You must log, metric-tag, or add context before returning. ? returns immediately, so no statement you write after it will run:

let rows = db.query(sql).map_err(|e| {
    warn!(error = %e, sql, "query failed");
    e                       // hand the error back unchanged
})?;

map_err wraps the failure, lets you observe it, and still returns it for ? to consume. The std-only alternative since Rust 1.76 is inspect_err, which runs a closure on the error and returns the original untouched — db.query(sql).inspect_err(|e| error!(%e))?; — cleaner when you only need to look. When you need control flow inside the error branch (retry, fall back to a cache, then maybe return), only an explicit match fits:

match db.query(sql) {
    Ok(rows) => rows,
    Err(e) if is_transient(&e) => cache.serve(key),
    Err(e) => return Err(AppError::from(e)),
}

2. You must recover partially, not propagate. ? is all-or-nothing. Parsing a batch where individual items may be bad needs per-item handling:

let parsed: Vec<Item> = raw.iter()
    .filter_map(|line| match line.parse() {
        Ok(item) => Some(item),
        Err(e) => { skipped += 1; debug!("skipping {line:?}: {e}"); None }
    })
    .collect();

3. The error type must be adapted, not just converted. Converting io::Error into AppError::NotFound by inspecting the OS code is a decision From cannot make (it receives no context about which file), so you do it explicitly:

fn open(path: &Path) -> Result<File, AppError> {
    fs::open(path).map_err(|e| match e.kind() {
        io::ErrorKind::NotFound => AppError::NotFound(path.display().to_string()),
        io::ErrorKind::PermissionDenied => AppError::Forbidden(path.display().to_string()),
        _ => AppError::Io(e),
    })
}

Note the ownership detail that trips people up: ? moves the error into the return value. If the function still needs to read from it — format it twice, push it into a log and return it — take a reference first (e.to_string(), &e) or clone. Errors are rarely Copy, and this is where "use of moved value: e" errors come from.

Async code adds no new rules: await first, then ? — let body = client.get(url).send().await?; — because ? operates on the awaited Result, not on the future. Inside map/and_then closures you hit the missing-return-type problem from the first chapter; annotate the closure return type and keep using ? normally.

Finally, the discipline that keeps ? honest: propagate typed errors with ?, record context with map_err/inspect_err, and reserve raw match for branches that genuinely do something else. When every function in the stack uses that split, the error report a user sees still has the file name, the SQL, and the OS code attached — which is exactly what building Display and source() on your own enum is for, and the next topic in this roadmap.

The three compiler errors you will meet with ?, and the one-line fix for each:

Error text Cause Fix
the `?` operator can only be used on `Result` enclosing function returns () or another type change the return type to Result<T, E> or Option<T>
mismatched types / expected E, found F no From<F> for E add impl From<F> for E, or .map_err(Into::into) explicitly
expected Result, found Option`` (or the reverse) crossing the two families .ok()? or .ok_or(err)?
cannot return value referencing ... / borrow errors ? moved a value you still use read it (&e, e.to_string()) before propagating

Quiz

1. Inside `fn drain(conn: &mut Conn) -> Result<(), io::Error> { loop { let m = conn.recv()?; ... } }`, what does `?` do when `recv` fails?

Question 1 options

2. A function returning `Result<Vault, ConfigError>` calls one returning `Result<Key, std::io::Error>` with `?`. What must exist for it to compile?

Question 2 options

3. You have `let opt: Option<u16> = ...;` inside a function returning `Result<u16, AppError>`. What is the correct way to use `?` on it?

Question 3 options

4. You need to log a query failure with its SQL and then still return that error. Which is idiomatic?

Question 4 options

Flashcards

Question

Write the exact desugaring of `expr?` in a function returning `Result<T, E>`.

Answer

`match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }` — unwrap on success, and on failure convert the error with `From::from` then return from the *enclosing function*, not the current block or loop.

Question

Why does `?` compile when error types differ, and what is required?

Answer

Because it inserts `E::from(err)`. You must provide `impl From<IncomingError> for OutgoingError`; identical types work via the blanket `impl<T> From<T> for T`. Without a matching `From` the compiler reports a mismatched-types error at the `?`.

Question

Which Result/Option combinations does `?` support, and how do you bridge the ones it does not?

Answer

Result→Result (with `From`) and Option→Option work. Result→Option fails (use `.ok()?`) and Option→Result fails (use `.ok_or(err)?` or `.ok_or_else(f)`). There is no automatic conversion between the two families because `None` carries no error payload.

Question

What does `fn main() -> Result<(), Box<dyn Error>>` do with an Err, and what is the exit code?

Answer

The runtime prints the error with its `Debug` formatting to stderr and exits with code 1; returning `Ok(())` exits 0. It lets `?` be used at the top level with no `unwrap`, and `Box<dyn Error>` has a std `From` impl so nearly any error converts into it.

Question

When do you write `map_err`/`inspect_err` instead of plain `?`?

Answer

When something must happen *before* returning: attaching context, emitting a log/metric, or reclassifying the error (e.g. mapping `ErrorKind::NotFound` to a typed variant). `?` returns immediately, so any statement written after it never executes on the error path.

Revision Notes

Key Takeaways

  • 1. `?` is an early return from the *function* plus `From::from` on the error — nothing more, nothing scoped to the current block
  • 2. Converting error types is the operator's hidden cost: each distinct source error needs a `From` impl, which is exactly what `thiserror` will generate later
  • 3. `?` bridges `Result` to `Result` and `Option` to `Option` only; crossing between the families requires `.ok()` or `.ok_or()` written by you
  • 4. Logging, retries, and reclassification must happen *before* `?` runs, via `map_err`/`inspect_err` or an explicit `match` — code after `?` never executes on the failure path
  • 5. A `Result`-returning `main` turns `?` into a top-level error handler that prints with `Debug` and exits 1, eliminating the last `unwrap`s in the binary

Interview Tips

  • • "\"Desugar `?`\" — give the exact `match` with `Err(e) => return Err(e.into())`, then add that `return` targets the function, not the loop; the follow-up about `From` is then trivial to answer"
  • • "\"Why doesn't my `?` compile?\" — name the two usual causes: no `From<Inner> for Outer` impl, or mixing `Result` with `Option`; show the `.ok()` / `.ok_or()` bridge for the second"
  • • "\"How does `?` differ from Go's error handling?\" — same propagation shape, but Rust's conversion step lets each layer wrap the error in its own typed enum while preserving `source()` for the chain"
  • • "\"Show where `?` is the wrong tool\" — a retry, a fallback to cache, or a log-with-SQL; explain that `?` returns immediately so the observation must be attached with `map_err`/`inspect_err` beforehand"

Cheat Sheet

The ? Operator

Desugar:

expr?   // =  match expr { Ok(v) => v,
        //      Err(e) => return Err(E::from(e)) }

return leaves the enclosing function — never just the block, loop, or iteration.

Requirements:

  1. The enclosing function returns a Try type: Result<T, E> or Option<T> (or main -> Result<_, E>).
  2. For mismatched error types: impl From<Incoming> for Outgoing must exist (blanket impl<T> From<T> for T covers the identity case).

Conversion matrix:

Produces Returns Fix if it fails
Result<T, A> Result<U, B> add impl From<A> for B
Result<T, A> Option<T> .ok()?
Option<T> Result<T, E> .ok_or(err)? / .ok_or_else(f)?
Option<T> Option<T> works as-is

Before returning — observe or adapt:

db.query(sql).inspect_err(|e| error!(%e, sql))?;          // log, then propagate
db.query(sql).map_err(|e| { note(e); AppError::from(e) })?; // add context/variant
match db.query(sql) { ... }                                // need a real branch (retry/fallback)

Ownership: ? moves the error out; read it (e.to_string(), &e) before propagating if you still need it. Async: .await first, then ?.

Continue Learning

Before You Start

Related Reading