Learning Rust · Part III · Chapter 13

Error handling, formally

In Part I you met Option, Result, and the question mark by sight. This is the rigorous version: what Result really is, exactly how ? desugars, how From lets one error type swallow several, and how to build an error of your own that the language treats as a first-class citizen.

01

Two kinds of wrong, and which one Result is for

Most languages run every failure through one channel. In Python a missing file, a bad cast, and a logic bug that indexes past the end of a list all arrive as exceptions, and you wrap a try around whichever ones you remember. Rust draws a line through the middle of that pile. Some failures are expected: the file might not exist, the input might not parse, the network might be down. You plan for these, you recover, you carry on. Other failures are bugs: you indexed an array out of bounds, you divided by a zero you swore could never be zero. These are not conditions to recover from; they are mistakes to fix.

Rust gives each kind its own mechanism. Expected failure is a value: a Result<T, E> you return and the caller handles. A bug is a panic: the thread unwinds and stops. The program below shows both side by side. Read it and press Run:

two_kinds_of_wrong.rs
// Two kinds of going wrong: one you recover from, one you do not.
fn halve(n: u32) -> Result<u32, String> {
    if n % 2 == 0 {
        Ok(n / 2)
    } else {
        Err(format!("{n} is odd, cannot halve cleanly"))
    }
}

fn main() {
    // A recoverable error: handle it and keep going.
    match halve(7) {
        Ok(h) => println!("half is {h}"),
        Err(why) => println!("recovered: {why}"),
    }

    // An unrecoverable error: an array index past the end is a bug, not a
    // value you negotiate with. It panics. We stay inside the bounds here so
    // the program runs; the comment shows the line that would crash.
    let primes = [2, 3, 5, 7, 11];
    let third = primes[2]; // in bounds: fine
    println!("third prime is {third}");
    // primes[99]; // would panic: index out of bounds. A bug, not a Result.
}

The call to halve(7) fails in the recoverable way: 7 is odd, so the function hands back an Err carrying a sentence, and the match at the call site prints it and moves on. Nothing crashes; the failure was always going to be possible, so it came back as a value. The array index, by contrast, is in bounds here on purpose. The commented line primes[99] would not return an error; it would panic, because reading past the end of an array is never a thing the program is supposed to do. One is a fork in the road you planned for; the other is the road giving out from under you.

Recoverable versus unrecoverable

Ask one question to choose between them: could a correct program reasonably hit this? A user typing a bad port, yes: that is a Result. Indexing a five-element array at position 99, no: that is a bug, so panic! is the honest response. Use Result for the failures you expect and plan to handle, and reserve panics for the assumption-violated, this-should-never-happen kind.

02

Result in depth: an enum with two variants

You used Result in Part I as a black box you matched on. Here is what is inside it. Result<T, E> is an ordinary enum, exactly the kind you built in III.07, with two variants and two type parameters: Ok(T) for the success value and Err(E) for the failure. Nothing magic, no compiler-blessed special case. If Result were not in the standard library you could write it yourself in three lines, and the match from III.08 would take it apart the same way it takes apart any enum.

The two type parameters are the whole story. T is what you get when it works; E is what you get when it does not. They are independent: a function can return Result<u16, String> (success is a port number, failure is a message) or Result<User, DbError> (success is a record, failure is a database problem). Because E sits right in the return type, the kinds of failure a call can produce are written down where every caller can read them, before running a single line.

Result carries a small pile of methods for the common moves, and you have already seen several: map transforms the Ok value and leaves an Err untouched, map_err does the mirror image, unwrap_or supplies a fallback for the failing case. These are conveniences built on the same two variants; anything they do, a match could do by hand. The reason they chain so cleanly, that Result is shaped to be mapped over, is the subject of the monads chapter in Part IV. For now treat them as named shortcuts for matches you would rather not write out.

Result is #[must_use]

The standard library marks Result as must_use, so calling a fallible function and throwing the whole Result away earns a compiler warning. The language nudges you to handle, propagate, or at least explicitly discard the failure. If you genuinely mean to ignore it, assign it to let _ = ... and the warning goes quiet, on the record that you chose to.

03

The ? operator, desugared exactly

In Part I you read ? as a one-character if err != nil { return err }. That is the right intuition, and now you get the precise rule, because two things happen at a ? and the second one is easy to miss. Here is the program that shows the first: the long-hand match and the short-hand ?, doing the identical thing, one above the other. Run it:

the_same_thing_twice.rs
// The ? operator, written out by hand next to its short form, so you can see
// they are the same control flow.
fn parse_two(a: &str, b: &str) -> Result<(u32, u32), std::num::ParseIntError> {
    // The long way: match, bind on Ok, early-return the Err.
    let x = match a.parse::<u32>() {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    // The short way: ? does exactly that match for you.
    let y = b.parse::<u32>()?;
    Ok((x, y))
}

fn main() {
    println!("{:?}", parse_two("10", "20"));
    println!("{:?}", parse_two("10", "two")); // the second ? returns the Err
}

The two bindings in parse_two have the same effect. The first writes the control flow out by hand: match on the Result, bind the inner value on Ok, return the error on Err. The second replaces all of that with ?. When both parses succeed you get Ok((10, 20)); when the second input is "two", the ? on that line hits an Err and returns it from the whole function immediately, which is why the second print shows the ParseIntError and the Ok((x, y)) at the bottom never runs.

What ? does, in full

expr? on a Result means, precisely: match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }. Two things happen on the error path. It early-returns the failure from the current function, and on the way out it runs From::from on the error, converting it to whatever error type this function returns. On the success path you simply get the inner value and carry on. On an Option the shape is the same with None in place of Err and no conversion step.

That From::from in the desugaring is the part the Part I treatment waved at, and it is the hinge of this whole chapter. It means a ? does not just propagate the error; it converts it first, using the From trait you met in III.12. If the function returns Result<_, BigError> and the failing call produced a SmallError, the ? will look for an impl From<SmallError> for BigErrorand apply it, silently, on the error’s way up. When the two error types are the same, From::from is the identity and converts nothing. The next two sections cash this in.

? needs a return type that can hold the error

? early-returns, so it only compiles in a function whose return type can receive what it returns: a Result for a Result ?, an Option for an Option ?. And the From::from step must have a conversion to apply; if there is no impl From<SmallError> for BigError, the ? fails to compile (error E0277, the trait bound is not satisfied) until you write one.

04

A custom error enum, made first-class

A String error is fine for a sketch, but it throws away structure: the caller gets a sentence, not a thing it can branch on. When the ways a function can fail are few and known, the idiomatic move is an enum with one variant per failure mode, some carrying data about what went wrong. That much you could do with III.07 alone. What makes it a first-class error, the kind the rest of the ecosystem will accept, is implementing two standard traits on it: Display for a human-readable message, and std::error::Error to mark it as an error type. Run this:

an_error_of_your_own.rs
// A custom error enum: one variant per way the parse can fail, plus the
// std::error::Error and Display impls that make it a first-class error type.
use std::fmt;

#[derive(Debug)]
enum SettingError {
    Empty,
    MissingEquals,
    BadNumber(String),
}

impl fmt::Display for SettingError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SettingError::Empty => write!(f, "the setting line was empty"),
            SettingError::MissingEquals => write!(f, "no '=' in the setting line"),
            SettingError::BadNumber(s) => write!(f, "{s:?} is not a number"),
        }
    }
}

// The requirements of std::error::Error are Debug + Display, both above, so the
// body can stay empty: the default methods are enough.
impl std::error::Error for SettingError {}

// Parse a "key = number" line into a (key, value) pair.
fn parse_setting(line: &str) -> Result<(String, u32), SettingError> {
    let line = line.trim();
    if line.is_empty() {
        return Err(SettingError::Empty);
    }
    let (key, value) = line.split_once('=').ok_or(SettingError::MissingEquals)?;
    let value = value
        .trim()
        .parse::<u32>()
        .map_err(|_| SettingError::BadNumber(value.trim().to_string()))?;
    Ok((key.trim().to_string(), value))
}

fn main() {
    for line in ["port = 8080", "", "host localhost", "retries = lots"] {
        match parse_setting(line) {
            Ok((k, v)) => println!("ok    {k} -> {v}"),
            // Display gives a readable sentence; Debug ({e:?}) shows the variant.
            Err(e) => println!("error {e}  [{e:?}]"),
        }
    }
}

SettingError names three failure modes, and BadNumber carries the offending text so the message can quote it. The #[derive(Debug)] gives the programmer-facing form you see in [{e:?}]: Empty, MissingEquals, BadNumber("lots"). The hand-written Display gives the user-facing sentence you see printed as {e}. Two audiences, two renderings, from the same value.

Notice the impl std::error::Error for SettingError {} with an empty body. The Error trait requires that a type already be Debug and Display (those are its supertraits), and every method it defines has a default. So once you have derived Debug and written Display, satisfying Error is a formality: the empty implsays "yes, this is an error type," and the defaults handle the rest. That single line is what lets your enum slot in anywhere a generic dyn std::error::Error is expected, which you will see in section 07.

The two traits, and why each

Displayanswers "what do I print for a human?" and you write it yourself, because only you know the right sentence. std::error::Erroranswers "is this an error type?" and you usually leave it empty, because its job is to mark the type and expose the standard error interface (like an optional source() for the underlying cause). Derive Debug for the programmer’s view, write Display for the human’s, add the empty Error impl to make it official.

05

From, the impl that lets one error swallow several

Now the payoff of the From::from hidden in ?. A real function rarely fails in just one way. It calls a parser that yields a ParseIntError, then runs its own validation that yields something else, and it would like to return a single, tidy error type that covers both. You saw in III.12 that Fromis the trait for "build one type out of another." Here is the move: give your error type a variant for each foreign error, then write an impl From that wraps the foreign error in that variant. After that, ? does the wrapping for you. Run it:

from_does_the_wrapping.rs
// One error type, three sources of failure. The From impl lets ? convert an
// underlying error into our own type on its way out, so the pipeline stays a
// straight line.
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum LoadError {
    Empty,
    Number(ParseIntError),
    OutOfRange(u32),
}

impl fmt::Display for LoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            LoadError::Empty => write!(f, "no value given"),
            LoadError::Number(e) => write!(f, "not a number: {e}"),
            LoadError::OutOfRange(n) => write!(f, "{n} is not a valid port"),
        }
    }
}

impl std::error::Error for LoadError {}

// This impl is the whole trick: it teaches ? how to turn a ParseIntError into a
// LoadError. Now text.parse::<u32>()? just works inside -> Result<_, LoadError>.
impl From<ParseIntError> for LoadError {
    fn from(e: ParseIntError) -> Self {
        LoadError::Number(e)
    }
}

fn load_port(text: &str) -> Result<u16, LoadError> {
    let text = text.trim();
    if text.is_empty() {
        return Err(LoadError::Empty);
    }
    let n: u32 = text.parse()?; // ParseIntError -> LoadError via From, by ?
    if n == 0 || n > 65535 {
        return Err(LoadError::OutOfRange(n));
    }
    Ok(n as u16)
}

fn main() {
    for text in ["8080", "  ", "https", "70000"] {
        match load_port(text) {
            Ok(p) => println!("{text:?} -> port {p}"),
            Err(e) => println!("{text:?} -> {e}"),
        }
    }
}

Look at load_port. The line let n: u32 = text.parse()?; calls a parser that fails with ParseIntError, but the function returns Result<u16, LoadError>. Those error types do not match, yet it compiles, because the ? applies From::from on the way out, and you supplied an impl From<ParseIntError> for LoadError that wraps it in the LoadError::Number variant. The parse error is converted to your type automatically; you never wrote the wrapping at the call site. The other two failures, an empty string and an out-of-range number, you construct directly as LoadError variants, because they are your own checks, not a foreign call.

This is why a function can stay a flat sequence of ? lines even when its steps fail in three unrelated ways. Each foreign error type gets one From impl, written once, and from then on every ? that produces that error funnels it into your enum without ceremony. The error types unify on the way up; the happy path stays a straight column. This is exactly the machinery the ergonomic crates in Part V (you may have heard the name thiserror) generate for you, but it is worth doing by hand once so the generated code holds no mystery.

Why the conversion is invisible but not magic

Nothing in text.parse()? mentions LoadError, which can feel like the compiler guessing. It is not guessing: the return type of load_port fixes the target error type, the ? desugars to From::from(e), and the compiler resolves that to your impl. Remove the impl From<ParseIntError> for LoadError and the line stops compiling at once. The conversion is fully explicit in the type system; it is just spelled with one character.

06

Crossing between Option and Result

Absence and failure are different shapes, but real code constantly turns one into the other. A lookup returns None and you need a Result so that a ? can carry the missing-key failure up with a reason. Or a parse returns a Result and you only care whether it worked, not why it did not, so you want a plain Option. The standard library gives you a small, named bridge in each direction. Run this:

bridging_the_two.rs
// Crossing the bridge between Option and Result, in both directions.
fn lookup(key: &str) -> Option<&'static str> {
    match key {
        "host" => Some("localhost"),
        "port" => Some("8080"),
        _ => None,
    }
}

// ok_or turns a None into an Err with the reason you supply, so an absence
// becomes a failure the ? operator can propagate.
fn required(key: &str) -> Result<&'static str, String> {
    lookup(key).ok_or(format!("missing required key {key:?}"))
}

fn main() {
    // ok_or: Option -> Result, giving the None case a reason.
    println!("{:?}", required("host"));
    println!("{:?}", required("timeout"));

    // ok_or_else: same, but the reason is built lazily, only when it is None.
    let v = lookup("nope").ok_or_else(|| "computed only on miss".to_string());
    println!("{v:?}");

    // .ok(): Result -> Option, throwing the error away when you do not need it.
    let parsed: Option<u32> = "8080".parse::<u32>().ok();
    let failed: Option<u32> = "oops".parse::<u32>().ok();
    println!("parsed {parsed:?}, failed {failed:?}");
}

Reading top to bottom: ok_or turns an Option<T> into a Result<T, E>, mapping Some(v) to Ok(v) and None to the Err you supply. That is how an absent value becomes a failure with a reason, ready to be propagated. ok_or_else does the same but takes a closure, so the error value is built only when it is actually None: prefer it when constructing the error is expensive or allocates, since on the Some path the closure never runs. Going the other way, .ok() turns a Result<T, E> into an Option<T>, keeping Ok(v) as Some(v) and discarding the error entirely on Err.

ok_or versus ok_or_else

The _else suffix shows up all over the standard library (unwrap_or vs unwrap_or_else, map_or vs map_or_else) and it always means the same thing: the plain form takes a ready-made value, evaluated whether or not it is needed; the _else form takes a closure, evaluated only on the path that needs it. Use the eager form for a cheap constant and the lazy form for anything that costs work to build.

07

unwrap, expect, and main returning Result

Sometimes you genuinely know a value is present, and a panic on the impossible case is the right answer. unwrap and expect are the deliberate doors out of the value world: both hand you the inner value on Ok or Some, and both panic on Err or None. The difference is the crash message: unwrap prints a generic note, expect prints the sentence you wrote. In real code an unwrap is a claim by the author that this cannot fail, so prefer expect: its message tells the next reader why you were certain, which is the one thing you want in the log when the certainty turns out to be wrong.

They are honest when success is genuinely guaranteed: a hardcoded literal, a value you validated three lines up, a test where a panic is the failure report. They are a loaded gun on a value that came from the network, a file, or a user, where None or Err is a normal Tuesday and a panic takes the process down. The rule of thumb: if a correct program could reach the failing case, do not unwrap it.

At the top of the program there is a better option than unwrapping, and you saw it in Part I: main can itself return Result. That lets ? work at the top level, and if main returns an Err, the runtime prints the error’s Debug form and exits with a non-zero status. Run this:

main_returns_result.rs
// main can return Result. When it returns Err, the runtime prints the error's
// Debug form and exits with a non-zero status, so ? works at the top level.
use std::num::ParseIntError;

fn parse_all(words: &[&str]) -> Result<Vec<u32>, ParseIntError> {
    let mut out = Vec::new();
    for w in words {
        out.push(w.parse::<u32>()?); // first bad word leaves main with an Err
    }
    Ok(out)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let nums = parse_all(&["1", "2", "3"])?;
    let total: u32 = nums.iter().sum();
    println!("sum is {total}");

    // Try it: add "x" to the slice above and run again. main returns the Err,
    // the runtime prints it, and the process exits non-zero.
    Ok(())
}

The return type here is Result<(), Box<dyn std::error::Error>>, and that Box<dyn ...>is worth a sentence. It means "a heap-allocated, type-erased value that implements the Errortrait," in other words any error at all. It is the lazy, perfectly good choice for main and small programs: because every standard error type (including the ParseIntError from parse_all) implements Error, the ? can convert any of them into the box via the same From step, and you never have to name a single unifying enum. You pay a heap allocation and lose the ability to match on the specific variant, which is exactly why libraries prefer the named enum from section 05 and binaries often reach for the box.

Box<dyn Error> for main, a named enum for libraries

Box<dyn std::error::Error> accepts anything, so a caller can only print it, not branch on it. That is fine at the very top of a program, where the next step is to report and exit. Down in a library, where callers need to tell failures apart and react differently, return a concrete error enum so the variants stay visible. Box up high, enumerate down low.

08

The whole pipeline, read in full

Here is everything from this chapter in one program, the fresh fallible pipeline promised at the start. It reads key = value config lines, validates each, and prints an outcome per line, never crashing on a bad one. Read it top to bottom; every piece now has a name. Run it:

the_whole_pipeline.rs
// The whole chapter in one program: a multi-step fallible pipeline that reads
// "key=value" config lines, validates each, and reports the outcome per line
// without ever crashing.
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Blank,
    NoEquals,
    BadPort(ParseIntError),
    PortZero,
    UnknownKey(String),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Blank => write!(f, "blank line"),
            ConfigError::NoEquals => write!(f, "expected key=value"),
            ConfigError::BadPort(e) => write!(f, "port is not a number: {e}"),
            ConfigError::PortZero => write!(f, "port 0 is not allowed"),
            ConfigError::UnknownKey(k) => write!(f, "unknown key {k:?}"),
        }
    }
}

impl std::error::Error for ConfigError {}

// ? turns a ParseIntError into ConfigError automatically through this impl.
impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadPort(e)
    }
}

enum Setting {
    Port(u16),
    Host(String),
}

impl fmt::Display for Setting {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Setting::Port(p) => write!(f, "port {p}"),
            Setting::Host(h) => write!(f, "host {h}"),
        }
    }
}

fn parse_line(line: &str) -> Result<Setting, ConfigError> {
    let line = line.trim();
    if line.is_empty() {
        return Err(ConfigError::Blank);
    }
    let (key, value) = line.split_once('=').ok_or(ConfigError::NoEquals)?;
    let key = key.trim();
    let value = value.trim();
    match key {
        "port" => {
            let n: u32 = value.parse()?; // ParseIntError -> ConfigError by ?
            if n == 0 {
                return Err(ConfigError::PortZero);
            }
            // Narrow into u16 range without panicking on huge inputs.
            let n = u16::try_from(n).map_err(|_| ConfigError::PortZero)?;
            Ok(Setting::Port(n))
        }
        "host" => Ok(Setting::Host(value.to_string())),
        other => Err(ConfigError::UnknownKey(other.to_string())),
    }
}

fn main() {
    let lines = [
        "port = 8080",
        "host = example.com",
        "",
        "timeout 30",
        "port = zero",
        "port = 0",
        "color = blue",
    ];
    for line in lines {
        match parse_line(line) {
            Ok(setting) => println!("ok    {setting}"),
            Err(e) => println!("skip  {e}"),
        }
    }
}

ConfigError is the custom enum of section 04: one variant per failure mode, with Display and the empty Error impl that make it first-class. The impl From<ParseIntError> for ConfigError from section 05 is what lets value.parse()? sit on a straight line inside parse_line even though parse fails with a different type; the ? converts it into ConfigError::BadPort on the way out. The split_once('=').ok_or(ConfigError::NoEquals)? is the Option-to-Result bridge of section 06, turning a missing = into a propagatable failure. And the match in main is the edge where both outcomes are handled: Ok prints the parsed setting, Err prints the reason it was skipped. No exception travels invisibly; every failure is a value the compiler made sure you accounted for.

Two threads run past this chapter on purpose. The reason Option, Result, and the chainable methods on both (map, and_then, the iterators you will meet next) feel like one idea in different clothes is that they genuinely share one shape; the monads chapter in Part IV is where that shape is named and its rules are stated. And the boilerplate of writing Display, the Error impl, and a From per source by hand is exactly what the crates thiserror and anyhow automate; that is a Part V story. You now know what they generate, which is the best possible position to use them from.

The whole chapter in six lines

  1. Two kinds of wrong: expected failures are Result values you recover from; bugs are panic! that ends the thread.
  2. Result<T, E> is just an enum with Ok(T) and Err(E); the error type Eis the author’s choice and lives in the signature.
  3. ? desugars to match e { Ok(v) => v, Err(e) => return Err(From::from(e)) }: early-return plus a From conversion.
  4. A custom error enum becomes first-class with derive(Debug), a hand-written Display, and an empty impl std::error::Error.
  5. From impls let one error type swallow several, so a function failing many ways stays a flat column of ? lines.
  6. Cross the bridge with ok_or / ok_or_else (Option to Result) and .ok() (Result to Option); use expect only when success is truly guaranteed, and let main return Result.
End-of-chapter assessmentCheck your understanding20 questions. Your first attempt is recorded on your report card.Take it →