Trust is earned, not given

A different perspective

2022-06-14 · Projects

Rust, part 2: Result, the ? operator, and errors a caller can act on

Part 2from the Rust series · 15 parts in all

Rust has no exceptions. It has a type, Result<T, E>, and an operator, ?, and the pair are why the language's error handling is the part other ecosystems keep copying in spirit. Part 2 is the model and the small ecosystem around it.

Errors are values, and the compiler makes you look

use std::fs;
use std::num::ParseIntError;

fn read_port(path: &str) -> Result<u16, ParseIntError> {
    let text = fs::read_to_string(path).unwrap_or_default();
    text.trim().parse::<u16>()          // the only ? worth propagating here
}

? is the whole trick: if the value is Err, return it from the function immediately; if it is Ok, unwrap it and carry on. The enclosing function must return a compatible Result, which is the compiler forcing the error path to be part of the signature — a caller cannot forget that failure is possible because the type says so.

The corollary is that unwrap() and expect() are not "bad style"; they are a claim that failure is impossible. Each one is a panic waiting for an assumption to break, so put a reason in every expect().

Two crates, one convention

The ecosystem settled on a split that is worth copying in your own code:

// A LIBRARY: enumerate the failure modes as a type, so callers can match on them.
use thiserror::Error;

#[derive(Debug, Error)]
pub enum LoadError {
    #[error("could not read {path}")]
    Io { path: String, #[source] source: std::io::Error },
    #[error("invalid config file")]
    Parse(#[from] toml::de::Error),
}

// An APPLICATION: you only ever log or report, so one opaque error is enough.
use anyhow::{Context, Result};

fn bootstrap() -> Result<()> {
    let cfg = std::fs::read_to_string("app.toml")
        .context("reading app.toml")?;
    let cfg: Config = toml::from_str(&cfg)?;
    start(cfg).context("starting the server")?;
    Ok(())
}

thiserror writes the boilerplate Display, Error and From impls for an enum of failure modes. anyhow gives you a single boxed error type with a context chain, which is what you want in a binary where nothing downstream is going to match on the variant. Using anyhow in a published library is the common mistake: your callers lose the ability to distinguish your failures.

Panics and aborting are for bugs

Rust separates two things most languages blend: recoverable failure, which is a Result, and a broken invariant, which is panic! — an unreachable match arm, an index out of bounds, a failed assertion in a test. Indexing v[i] panics; v.get(i) returns Option. Both exist because both needs exist. Next: the abstraction that makes Rust's iterators as fast as a hand-written loop.