Skip to content

08 - Error Handling

What this session is

About an hour. You'll learn Rust's Result<T, E> type, the ? operator, the distinction between recoverable failures (errors) and unrecoverable bugs (panics), and how to build your own error types.

Rust's error story is more like Go's "errors as values" than Java's "throw an exception" - and the type system enforces it.

Result<T, E>: success or failure

Rust's built-in enum for fallible operations:

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

T is the success type, E is the error type. A function that might fail returns Result<T, E>. The caller must handle both cases.

Result is everywhere in the standard library:

use std::num::ParseIntError;

fn parse_age(s: &str) -> Result<u32, ParseIntError> {
    s.parse::<u32>()       // returns Result<u32, ParseIntError>
}

fn main() {
    match parse_age("30") {
        Ok(n) => println!("got {n}"),
        Err(e) => println!("error: {e}"),
    }

    match parse_age("hello") {
        Ok(n) => println!("got {n}"),
        Err(e) => println!("error: {e}"),
    }
}

Output:

got 30
error: invalid digit found in string

The compiler forces you to handle the Err case via match (or via methods like .unwrap(), .expect(), .ok() - see below). You can't accidentally ignore the failure.

Quick (and risky) ways to extract a value

When you're prototyping:

let n: u32 = "30".parse().unwrap();        // crash on error
let n: u32 = "30".parse().expect("bad age");  // crash with message
  • .unwrap() - returns the Ok value; panics on Err.
  • .expect("msg") - same, but panics with the given message.

Use these in prototypes, tests, and "this can't fail and if it does the program is broken" situations. In production code, you should usually handle the error properly.

The ? operator: propagating errors

Writing nested matches to propagate errors is tedious:

fn double_age(s: &str) -> Result<u32, ParseIntError> {
    match s.parse::<u32>() {
        Ok(n) => Ok(n * 2),
        Err(e) => Err(e),
    }
}

The ? operator does the same, much shorter:

fn double_age(s: &str) -> Result<u32, ParseIntError> {
    let n = s.parse::<u32>()?;     // if Err, return the error
    Ok(n * 2)                       // otherwise continue
}

The ? reads: "if this is Ok, give me the value; if Err, return the Err from the enclosing function." Two characters that change everything.

Chain them:

fn process(input: &str) -> Result<u32, ParseIntError> {
    let n = input.trim().parse::<u32>()?;
    let doubled = (n * 2).to_string().parse::<u32>()?;
    Ok(doubled)
}

Each ? is "early-return on error." Clean.

The ? operator works on Result (returning Err to the caller) and on Option (returning None). The function's return type has to be the right shape.

panic!: when the program is fundamentally broken

For situations that should never happen - bugs, invariant violations - use panic!:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("divide by zero");
    }
    a / b
}

Panic unwinds the stack and aborts the program. Use sparingly - for "this represents a bug in my code, the only sane response is to crash with diagnostics."

unwrap and expect panic on Err. Index-out-of-bounds (vec[100]) panics. Division by zero on integers panics. These are conditions you should program around in production code.

Custom error types

For a real application, define your own error type - usually an enum:

#[derive(Debug)]
enum BankError {
    InsufficientFunds { balance: f64, requested: f64 },
    NegativeAmount(f64),
    AccountClosed,
}

fn withdraw(balance: f64, amount: f64) -> Result<f64, BankError> {
    if amount <= 0.0 {
        return Err(BankError::NegativeAmount(amount));
    }
    if amount > balance {
        return Err(BankError::InsufficientFunds {
            balance,
            requested: amount,
        });
    }
    Ok(balance - amount)
}

fn main() {
    match withdraw(100.0, 50.0) {
        Ok(new_balance) => println!("new balance: {new_balance}"),
        Err(e) => println!("error: {e:?}"),
    }
    match withdraw(100.0, 200.0) {
        Ok(new_balance) => println!("new balance: {new_balance}"),
        Err(e) => println!("error: {e:?}"),
    }
}

The #[derive(Debug)] on the enum auto-generates the Debug trait so {e:?} works. We'll meet derive in page 09.

For a complete error type that integrates with the standard library, you'd implement Display and std::error::Error. Most projects use the thiserror crate to derive these automatically - we'll meet crates in page 11.

A real example: read a config file

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

fn load_config(path: &str) -> Result<String, io::Error> {
    let contents = fs::read_to_string(path)?;     // ? propagates IO errors
    Ok(contents.trim().to_string())
}

fn main() {
    match load_config("config.txt") {
        Ok(cfg) => println!("loaded: {cfg}"),
        Err(e) => println!("config load failed: {e}"),
    }
}

fs::read_to_string returns Result<String, io::Error>. The ? propagates the error if it happens. Clean.

When to use what

  • unwrap / expect - prototypes, tests, "impossible in this context" cases.
  • match - when you want to handle both Ok and Err inline with different logic.
  • ? - when you want to propagate the error to the caller (most function bodies).
  • Result<T, E> with custom E - when your function can fail in distinct ways.
  • panic! - when a programming invariant is violated.

The full Rust error-handling story (custom Error trait, error contexts via anyhow/thiserror) gets richer. For now, these patterns cover 90%.

Exercise

Replace src/main.rs:

Write a function fn parse_positive(s: &str) -> Result<u32, String>:

  1. Trim the input. If it's empty, return Err("empty input".to_string()).
  2. Parse as u32. If parsing fails, return Err(format!("not a number: {s}")).
  3. If the number is 0, return Err("must be > 0".to_string()).
  4. Otherwise return Ok(n).

In main, loop over these inputs:

let inputs = ["42", "hello", "0", "", "100", "  7  "];

For each, call parse_positive and print success or error.

Stretch: define a #[derive(Debug)] enum ParseError { Empty, NotNumber(String), Zero }. Change parse_positive to return Result<u32, ParseError>. Use {:?} to print the error.

Bonus stretch: add a fn sum_inputs(inputs: &[&str]) -> Result<u32, ParseError> that parses all of them and returns the sum, using ? to short-circuit on the first error.

What you might wonder

"Why no exceptions?" Exceptions hide control flow - a function call may secretly jump 10 frames up the stack. Rust's design forces every failure path into the type system. The cost: more boilerplate (lots of ?). The benefit: every failure is visible at the call site; the compiler enforces handling.

"What about runtime errors like array index out of bounds?" Those panic. Panics are for "this code is broken" situations, not "expected failures." For safe indexing, use .get(i) which returns Option<&T>.

"Can ? propagate between different error types?" Yes, via the From trait. If your function returns Result<T, MyError> and you call a function returning Result<T, IoError>, ? automatically calls MyError::from(io_error) if the conversion is defined. We're not covering trait implementation here; recognize when you see it.

"What about anyhow and thiserror?" The two popular error-handling crates. anyhow::Result<T> is a flexible Result type for applications ("any error can happen, just give me a string"). thiserror derives Error for your enums in libraries. Real Rust code uses one or both. We'll mention them again in page 11.

Done

  • Recognize Result<T, E> as Rust's failure type.
  • Handle errors with match, unwrap, expect, or ?.
  • Propagate errors compactly with ?.
  • Define custom error enums.
  • Distinguish recoverable errors from panics.

Next: Traits and generics →

Comments