Coding by Hand
Rust home

Result and the ? Operator

A Result in Rust is a luggage tag on a bag at airport security. Every bag rolling down the conveyor belt has one of two tags. A green Ok tag means the bag passed the scanner and goes through. A red Err tag means a screener pulled it off the belt because something inside set off the alarm, and the tag itself says what tripped it. There is no third option. There is no bag without a tag. Once you accept that every function which might fail hands back a tagged bag, the rest of Rust's error story falls into place.

Two bags rolling off an airport x-ray belt — one tagged Ok in green, one tagged Err in red.
Two bags rolling off an airport x-ray belt — one tagged Ok in green, one tagged Err in red.

The reason Rust forces this on you traces back to a man named Tony Hoare and a decision he made in 1965. Hoare was designing a language called ALGOL W, and he added a value called null so a pointer could mean "nothing here yet." Forty years later he stood on a stage and called it his billion-dollar mistake, because every language that copied null — C, C++, Java, Python — let the programmer forget that any pointer could secretly be empty, and the world spent decades crashing on it. The fix was sitting in academia the whole time. A language called ML in the 70s, and later Haskell in 1990, used a tagged sum type called Either a b that held one of two things and made you check which before you used it. When Graydon Hoare (no relation) started Rust at Mozilla in 2006, he lifted that idea and renamed it Result<T, E>. The compiler will not let you reach into the bag without first asking which tag is on it.

Here is a tiny error type that lists every reason a config line might fail. Three variants, each one carrying the line number so the screener can tell you exactly which bag tripped which alarm.

#[derive(Debug)]
enum ConfigError {
    MissingEquals(usize),
    EmptyKey(usize),
    EmptyValue(usize, String),
}

The enum is the set of red tags. MissingEquals is the alarm for a line with no = in it. EmptyKey is the alarm for a line like = foo where the name is blank. EmptyValue carries both the line number and the key string, so the screener can say which bag was empty. Real programs grow this list as they discover new failure modes. Each new variant is a new color of red tag.

Now the function that scans one bag. It takes a line number and the line itself, and it hands back a Result with the green case carrying a (key, value) pair and the red case carrying one of those error tags.

fn parse_line(line_no: usize, line: &str) -> Result<(&str, &str), ConfigError> {
    let (key, value) = line
        .split_once('=')
        .ok_or(ConfigError::MissingEquals(line_no))?;
    let key = key.trim();
    let value = value.trim();
    if key.is_empty() {
        return Err(ConfigError::EmptyKey(line_no));
    }
    if value.is_empty() {
        return Err(ConfigError::EmptyValue(line_no, key.to_string()));
    }
    Ok((key, value))
}

Look at line 3. The split_once('=') method returns an Option, which is the same idea as Result but the red tag carries no message — it is just "nothing." The .ok_or(...) method converts that nothing into a real ConfigError::MissingEquals tag. Then the question mark at the end of that line is the move. It says: if this Result is Ok, pull the (key, value) out of the bag and keep going. If it is Err, stop the whole function right here and return that same Err to whoever called us. The ? is the pull-off-the-belt motion compressed into one keystroke. Without it, every line that might fail would need a four-line match block to do the same thing.

The ? operator was not in Rust 1.0. For the first two years, you wrote a macro called try!() around every fallible call, and the code looked like a forest of parentheses. A contributor named Aaron Turon proposed the postfix ? in 2016 because reading code from left to right got easier when the failure check happened at the end of the line instead of wrapped around it. The Rust team shipped it in version 1.13 and the macro fell out of use within a month. This is how Rust changes — slowly, only when the case is strong, and the old way still works for years after.

Here is a function that scans every line in a config string and stops at the first bad one.

fn load_first(config: &str) -> Result<Vec<(&str, &str)>, ConfigError> {
    let mut pairs = Vec::new();
    for (i, line) in config.lines().enumerate() {
        let pair = parse_line(i + 1, line)?;
        pairs.push(pair);
    }
    Ok(pairs)
}

The ? after parse_line(...) is doing the heavy lifting in a loop. If line 1 passes, the pair gets pushed and we move on. If line 2 has no =, the ? pulls the function off the belt and returns the MissingEquals(2) tag to main. We never see line 3. The whole config is rejected at the first alarm, which is what you want — a half-loaded config is more dangerous than no config.

The driver in main runs four little configs through the loader. The first one is clean. The second one has no = on line 2. The third one has a blank key. The fourth one has a key with no value. The match block reads the tag on the returned bag and prints what happened.

fn main() {
    let configs = [
        "host = localhost\nport = 8080\nuser = aarit",
        "host = localhost\nport 8080",
        "host = localhost\n = empty-key",
        "host = localhost\nport = ",
    ];
    for (i, config) in configs.iter().enumerate() {
        println!("--- config {} ---", i + 1);
        match load_first(config) {
            Ok(pairs) => {
                for (k, v) in &pairs {
                    println!("  ok: {k} -> {v}");
                }
            }
            Err(ConfigError::MissingEquals(n)) => {
                println!("  err: line {n} has no '='");
            }
            Err(ConfigError::EmptyKey(n)) => {
                println!("  err: line {n} key is empty");
            }
            Err(ConfigError::EmptyValue(n, k)) => {
                println!("  err: line {n} key '{k}' has no value");
            }
        }
    }
}

When you run the binary, the output is the same error-flow diagram the build was supposed to produce — one block per config, each one showing whether the bag made it through or which alarm fired.

--- config 1 ---
  ok: host -> localhost
  ok: port -> 8080
  ok: user -> aarit
--- config 2 ---
  err: line 2 has no '='
--- config 3 ---
  err: line 2 key is empty
--- config 4 ---
  err: line 2 key 'port' has no value

Notice that the match in main has one arm for every variant of ConfigError. If you add a new variant later and forget to handle it here, the compiler refuses to build. This is the second half of why Result works — the type system counts the red tags and makes sure every place that opens a bag has a plan for every color. The bag is never silently swallowed and never blindly trusted.

How the ? operator routes each line of a config — pass through on Ok, short-circuit on Err.
How the ? operator routes each line of a config — pass through on Ok, short-circuit on Err.

One question you should be asking — what if the four error variants in this lesson came from four different functions in four different files? You would have to write impl From<IoError> for ConfigError boilerplate by hand for every one, which is exactly the next bottleneck — and the thiserror crate is how the Rust world stopped writing that code.