rust · level 3

Error Handling

Result, Option, ?, panic vs Err, From conversions.

150 XP

Error Handling

Rust does not have exceptions. Failure is a value, returned through the type system, threaded explicitly through every potentially-failing call site. After the initial shock — "wait, I have to look at every error?" — this becomes one of Rust's most loved features. Failure modes can't hide.

Two enums do almost everything

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option<T> represents absence. Result<T, E> represents failure with information. The two cover everything that would be null, undefined, throw, or "errno" in another language.

Rule of thumb: Option for "might not be there", Result for "might have gone wrong". If a lookup that misses isn't an error in your domain, return Option. If the user typed a malformed URL, return Result.

The ? operator

The single best ergonomic improvement Rust ever shipped. At the end of a fallible expression, ? says: "if this is Err, return the error from this function; otherwise unwrap the Ok":

use std::fs;
use std::io;

fn read_config() -> Result<String, io::Error> {
    let s = fs::read_to_string("/etc/app.conf")?;   // returns Err if read fails
    Ok(s.trim().to_string())
}

? desugars to roughly:

let s = match fs::read_to_string("/etc/app.conf") {
    Ok(v) => v,
    Err(e) => return Err(e.into()),     // ← that .into() is doing a lot
};

Two crucial details: it short-circuits the function (so all subsequent code only runs on success), and it calls .into() on the error — which leans on the From trait to convert.

From conversions and error types

That .into() lets you mix error types:

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

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(ParseIntError),
}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}

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

fn parse_port() -> Result<u16, AppError> {
    let s = fs::read_to_string("/etc/port")?;          // io::Error → AppError::Io
    let n: u16 = s.trim().parse()?;                    // ParseIntError → AppError::Parse
    Ok(n)
}

Two ? operators, two different source error types, both flow into one AppError. The From impls are the glue. In practice you'd use thiserror to derive these:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),
}

#[from] generates the From impl for you.

Option ergonomics

Option<T> has a similar suite of methods:

let users: Vec<&str> = vec!["a", "b", "c"];

// .first() returns Option<&&str>
let first = users.first().copied().unwrap_or("default");

// Chain transformations:
let upper = users.first()
    .map(|s| s.to_uppercase())          // Option<String>
    .unwrap_or_else(|| String::from("?"));

// Convert Option to Result with .ok_or:
fn lookup(id: u64) -> Result<User, AppError> {
    db.get(&id).ok_or(AppError::NotFound)
}

Key methods to memorise: unwrap_or, unwrap_or_else, map, and_then, ok_or, is_some, is_none. They compose into pipelines that read top-to-bottom.

panic! — for unrecoverable bugs only

let x: i32 = -1;
assert!(x >= 0, "x must be non-negative, got {}", x);   // panics if false

if some_invariant_violated() {
    panic!("expected sorted slice, found unsorted");
}

When to panic:

  • An invariant has been violated that means the program is in a bad state.
  • The condition can never legitimately occur (e.g. vec![1].first().unwrap()).
  • You're prototyping and don't yet care about error UX.

When not to panic:

  • I/O failure (the disk fills up, the network drops).
  • Bad user input (someone types not-a-number).
  • Optional file or env var missing.

The line between "bug" and "expected failure" is the line between panic! and Result. Library code should almost never panic on user input.

unwrap / expect

Two sharp tools for Option and Result:

let n: i32 = "42".parse().unwrap();                  // panic on Err
let n: i32 = "42".parse().expect("must be valid u8");// panic with message

Both panic on the failure variant. expect lets you supply a message — use it during development; replace with ? or proper handling before shipping. clippy will flag both in production paths if you ask.

Error type design

Two patterns dominate:

Library code: enum-per-domain.

#[derive(Debug, thiserror::Error)]
pub enum ParseError {
    #[error("unexpected token at byte {0}")]
    UnexpectedToken(usize),
    #[error("unterminated string starting at byte {0}")]
    UnterminatedString(usize),
}

Callers can match on variants and react differently to each.

Application code: a single boxed dyn Error.

fn main() -> anyhow::Result<()> {
    let cfg = read_config()?;
    let server = build_server(cfg)?;
    server.run()?;
    Ok(())
}

anyhow::Result<T> is Result<T, anyhow::Error> — a wrapper around Box<dyn Error + Send + Sync> plus context APIs (.with_context(|| "loading config")). You lose the ability to match on specific variants, but you save a lot of typing and errors get clean stack-trace-like reports.

A pragma worth knowing

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let s = std::fs::read_to_string("config.txt")?;
    println!("{}", s);
    Ok(())
}

main can return Result. Any ? inside propagates. The runtime prints the error using Debug and exits with status 1. Saves you from writing a custom error printer for CLIs.

Summary table

Situation Use
Value might be absent (lookup miss, optional field) Option<T>
Value might fail to load with reason (I/O, parse) Result<T, E>
Forwarding a fallible call inside another fallible function ?
Converting between error types automatically From impl + ?
Library: distinguishable errors callers can match on enum + thiserror
App: just stop on the first error with context anyhow::Result + ?
Truly impossible-to-recover bug panic! / unwrap

Rust's error handling is verbose at the call site by design — every potential failure point is visible. After a week the visibility stops feeling like noise and starts feeling like documentation.

Tools in the wild

4 tools
  • anyhowfree tier

    `anyhow::Result<T>` for application code where you want one universal error type.

    library
  • thiserrorfree tier

    Derive macro for clean library error enums — `#[derive(thiserror::Error)]` + `#[from]`.

    library
  • clippyfree tier

    Flags `unwrap()` / `expect()` in production paths; suggests `?` over `match err`.

    cli
  • color-eyrefree tier

    Anyhow-style errors with prettier reports + automatic spantraces.

    library