Level 4 · Deep Cuts

Error Handling Like a Pro 🧰

Lesson 11 taught you Result and ?. This lesson teaches what the pros actually ship: error types that explain themselves, convert into each other, and tell the user what to do about it.

Your own error type, by hand 🔨

String errors work, but an enum of everything that can go wrong is better: callers can match on it, and the compiler knows every failure mode. Making it a proper citizen takes two traits, Display (how it reads) and Error (the marker every error type shares):

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    NotANumber(String),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Missing(key) => write!(f, "missing setting '{key}'"),
            ConfigError::NotANumber(v) => write!(f, "'{v}' is not a number"),
        }
    }
}

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

fn port_from(line: Option<&str>) -> Result<u16, ConfigError> {
    let raw = line.ok_or(ConfigError::Missing("port".to_string()))?;
    raw.parse().map_err(|_| ConfigError::NotANumber(raw.to_string()))
}

fn main() {
    println!("{:?}", port_from(Some("7878")));
    println!("{}", port_from(Some("crab")).unwrap_err());
    println!("{}", port_from(None).unwrap_err());
}

Notice the error messages name the actual key and the actual bad value. That is the difference between a log line you curse at and one that fixes itself: an error is a message to a future debugger, probably you.

Mixing error types: Box<dyn Error> 📦

Real functions fail in several unrelated ways: the file read fails with an io::Error, the parse fails with a ParseIntError. A trait object (Lesson 15 knowledge!) accepts them all, and ? converts automatically:

use std::error::Error;
use std::fs;

fn first_line_number(path: &str) -> Result<i64, Box<dyn Error>> {
    let text = fs::read_to_string(path)?;                     // io::Error
    let first = text.lines().next().ok_or("file is empty")?;  // &str
    let n: i64 = first.trim().parse()?;                       // ParseIntError
    Ok(n)
}

fn main() {
    fs::write("number.txt", "42\n").expect("demo setup failed");
    match first_line_number("number.txt") {
        Ok(n) => println!("got {n}"),
        Err(e) => println!("error: {e}"),
    }
    match first_line_number("Cargo.toml") {
        Ok(n) => println!("got {n}"),
        Err(e) => println!("error: {e}"),
    }
}

Three different error types flow through one ? pipeline. The tradeoff: callers can no longer match on exactly what failed. Which brings us to the two crates the whole ecosystem settled on.

thiserror: for code others call 📚

That hand-written Display impl was boilerplate, and the thiserror crate (cargo add thiserror) generates it from attributes:

use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("missing setting '{0}'")]
    Missing(String),
    #[error("'{0}' is not a number")]
    NotANumber(String),
    #[error("could not read the config file")]
    Io(#[from] std::io::Error),
}

fn main() {
    println!("{}", ConfigError::Missing("port".into()));
    println!("{}", ConfigError::NotANumber("crab".into()));

    let io_err: ConfigError = std::io::Error::other("disk on fire").into();
    println!("{io_err}");
}

Same enum, a tenth of the code. The #[from] attribute is the quiet star: it writes the conversion that lets ? turn an io::Error into your ConfigError::Io automatically, while keeping the original error attached as the source underneath.

anyhow: for code only you call 🎒

Applications mostly just want errors to bubble up to main wearing a good explanation. anyhow (cargo add anyhow) gives you one flexible error type plus the killer feature, context:

use anyhow::{Context, Result};
use std::fs;

fn read_port(path: &str) -> Result<u16> {
    let text = fs::read_to_string(path)
        .with_context(|| format!("could not read {path}"))?;
    let port = text
        .trim()
        .parse()
        .context("the port file should contain just a number")?;
    Ok(port)
}

fn main() {
    fs::write("port.txt", "7878").expect("demo setup failed");
    println!("port: {}", read_port("port.txt").expect("should work"));

    if let Err(e) = read_port("missing.txt") {
        println!("{e:#}");
    }
}
port: 7878
could not read missing.txt: No such file or directory (os error 2)

Each context call wraps the error in another layer of story, and {e:#} prints the whole chain. The rule the ecosystem converged on: libraries use thiserror (callers need to match), applications use anyhow (humans need to read). This site's own Cloudflare Functions follow the application style.

So when do you panic? 💥

SituationReach for
The user gave bad inputResult, always. Bad input is normal life.
A file, network, or database let you downResult, with context added.
A bug: "this can never happen" happenedpanic! or unreachable!. Crashing honestly beats corrupting data.
Examples, prototypes, testsunwrap/expect are fine. Ship code prefers expect("why this is safe").
Exercise

The temperature parser

Write parse_temp(input: &str) -> Result<f64, TempError> that reads "21C" or "70F" and returns degrees Celsius. Design a thiserror enum with three variants: empty input, missing unit, and not-a-number. Every message must include the offending text. Test it on ["21C", "70F", "", "hotC", "30"].

Reveal solution
use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
enum TempError {
    #[error("empty input")]
    Empty,
    #[error("'{0}' needs a unit: end with C or F")]
    NoUnit(String),
    #[error("'{0}' is not a number")]
    BadNumber(String),
}

fn parse_temp(input: &str) -> Result<f64, TempError> {
    let s = input.trim();
    if s.is_empty() {
        return Err(TempError::Empty);
    }
    let (num, unit) = s.split_at(s.len() - 1);
    match unit {
        "C" | "c" | "F" | "f" => {
            let value: f64 = num
                .trim()
                .parse()
                .map_err(|_| TempError::BadNumber(num.trim().to_string()))?;
            if matches!(unit, "F" | "f") {
                Ok((value - 32.0) * 5.0 / 9.0)
            } else {
                Ok(value)
            }
        }
        _ => Err(TempError::NoUnit(s.to_string())),
    }
}

fn main() {
    for input in ["21C", "70F", "", "hotC", "30"] {
        match parse_temp(input) {
            Ok(c) => println!("{input:>5} -> {c:.1}°C"),
            Err(e) => println!("{input:>5} -> error: {e}"),
        }
    }
}

Look at the output for "hotC" versus "30": different mistakes get different, specific advice. That is the whole craft. Also sneaky: Lesson 19's matches! and or-patterns doing real work already. 🌡️

🧠 The takeaway Beginners handle errors to stop the compiler complaining. Pros design errors as part of the interface: enums when callers decide, context when humans read, panics only for impossible states. Your error messages are the user manual nobody skips.