GoByte Skills Episode 10 of 27, track Rust (1 of 3)

The ? operator: errors are values

GoByte Skills #10: The ? operator is a match you did not have to type. On Err it converts the error through From and returns early, so the error changes type on the way up and never leaves the signature. No exceptions, no unwinding.

Piere is one of GoByte's characters. This post was drafted by AI agents in Piere's voice, then fact checked, run and edited by the GoByte team.

Back to top

The ? operator returns early and converts the error through From, so errors compose without exceptions. No unwinding, no hidden exit: it is a match you did not have to type.

Animation for GoByte Skills #10: An error value travels up the call stack and changes type on the way.
Transcript

Rust code types in while an error value leaves parse, passes a From gate that turns it from ParseIntError into AppError, and climbs the call stack to main, which matches on it.

use std::num::ParseIntError;
use AppError::*;

enum AppError {
    Parse(ParseIntError),
    TooBig(u32),
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        Parse(e)
    }
}

fn port(s: &str) -> Result<u16, AppError> {
    let n: u32 = s.parse()?; // Err: convert, return
    if n > 65535 {
        return Err(TooBig(n));
    }
    Ok(n as u16)
}

fn main() {
    for s in ["8080", "http", "70000"] {
        match port(s) {
            Ok(p) => println!("{s}: port {p}"),
            Err(Parse(e)) => println!("{s}: {e}"),
            Err(TooBig(n)) => println!("{n}: too big"),
        }
    }
}

Output:

8080: port 8080
http: invalid digit found in string
70000: too big

The mechanism#

On a Result, expr? means roughly match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }. Two payoffs in one character: an early return, and a From conversion. That From::from is the whole trick. parse fails with a ParseIntError, but port promises an AppError. The compiler finds the From impl and inserts the call, so the error changes type on its way up. The signature still tells every caller exactly what can come back: the failure is an ordinary value in the return type, not a jump the type system never heard about.

Option too#

On an Option, ? returns None early. It will not mix the two silently: in a function returning Result<_, Box<dyn Error>>, turn a None into an error first with .ok_or("empty")?. In port that same line fails with E0277, because AppError has no From<&str>.

Where it stops#

? compiles only in a function whose return type can carry the failure. In a plain fn main() you get E0277: "the ? operator can only be used in a function that returns Result or Option (or another type that implements FromResidual)". The usual fix is fn main() -> Result<(), Box<dyn Error>>. Box<dyn Error> accepts any error through a blanket From impl: great for binaries, lazy for libraries, because callers can no longer match on the cause without downcasting.

Version note#

? was stabilized in Rust 1.13 to replace the try! macro, and learned Option in 1.22. Underneath it runs on the Try trait, which is still unstable to implement yourself.

Rule of thumb#

One error enum per library, one From impl per cause, ? everywhere else. An exception is a goto with a good publicist. ? is a return statement that read the signature first.

Report a mistake

Your product here? Partner with us

Back to top

Discussion

No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines

Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.