Error Handling

Rust has no exceptions. Instead it uses two types for handling errors: Result<T, E> for recoverable errors and panic! for unrecoverable ones. This forces you to handle errors explicitly.

For Python developers: This is a major paradigm shift! No try/except blocks. Instead, errors are values that you must explicitly handle. This prevents forgotten error handling at compile time.

Python vs Rust Error Handling

Python approach:

# Exceptions - runtime errors
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

try:
    result = divide(10, 0)
    print(result)
except ZeroDivisionError as e:
    print(f"Error: {e}")

Rust approach:

// Result type - compile-time safety
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

match divide(10, 0) {
    Ok(result) => println!("{}", result),
    Err(e) => println!("Error: {}", e),
}

Key differences:

  • Python: Errors can be ignored (leads to crashes)
  • Rust: Compiler forces you to handle Result
  • Python: try/except blocks
  • Rust: match expressions or ? operator

Unrecoverable Errors with panic!

When to Panic

Use panic! when your program reaches an unrecoverable state:

fn main() {
    panic!("crash and burn");
}

Output:

thread 'main' panicked at 'crash and burn', src/main.rs:2:5

Common Panic Situations

fn main() {
    // Index out of bounds
    let v = vec![1, 2, 3];
    // v[99];  // panics

    // Unwrap on None
    let x: Option<i32> = None;
    // x.unwrap();  // panics

    // Unwrap on Err
    let result: Result<i32, &str> = Err("bad");
    // result.unwrap();  // panics
}

Getting a Backtrace

RUST_BACKTRACE=1 cargo run

Recoverable Errors with Result

The Result Type

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

Basic Usage

use std::fs::File;

fn main() {
    let file_result = File::open("hello.txt");

    let file = match file_result {
        Ok(f) => f,
        Err(error) => {
            println!("Problem opening file: {}", error);
            return;
        }
    };

    println!("File opened successfully: {:?}", file);
}

Matching on Different Errors

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file = match File::open("hello.txt") {
        Ok(f) => f,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening file: {:?}", other_error);
            }
        },
    };
}

Cleaner with unwrap_or_else

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating file: {:?}", error);
            })
        } else {
            panic!("Problem opening file: {:?}", error);
        }
    });
}

Result Shortcuts

unwrap and expect

use std::fs::File;

fn main() {
    // unwrap: panic if Err, return value if Ok
    let f = File::open("hello.txt").unwrap();

    // expect: same as unwrap but with custom message
    let f = File::open("hello.txt")
        .expect("Failed to open hello.txt");
}

Use expect over unwrap - the error message helps debugging.

Result Methods

MethodOn Ok(v)On Err(e)
unwrap()Returns vPanics
expect("msg")Returns vPanics with "msg"
unwrap_or(default)Returns vReturns default
unwrap_or_else(f)Returns vReturns f(e)
unwrap_or_default()Returns vReturns T::default()
map(f)Returns Ok(f(v))Returns Err(e)
map_err(f)Returns Ok(v)Returns Err(f(e))
and_then(f)Returns f(v)Returns Err(e)
ok()Returns Some(v)Returns None
is_ok()truefalse
fn main() {
    let result: Result<i32, String> = Ok(42);

    // map: transform the success value
    let doubled = result.map(|v| v * 2);
    println!("{:?}", doubled);  // Ok(84)

    // and_then: chain operations that can fail
    let chained = result.and_then(|v| {
        if v > 0 { Ok(v * 2) } else { Err("negative".to_string()) }
    });
    println!("{:?}", chained);  // Ok(84)

    // Convert Result to Option
    let option = result.ok();
    println!("{:?}", option);  // Some(42)
}

The ? Operator

The ? operator is syntactic sugar for propagating errors. It returns the error early if the Result is Err, or unwraps the Ok value.

Without ?

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let file_result = File::open("username.txt");

    let mut file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

With ?

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut file = File::open("username.txt")?;
    let mut username = String::new();
    file.read_to_string(&mut username)?;
    Ok(username)
}

Chaining with ?

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();
    File::open("username.txt")?.read_to_string(&mut username)?;
    Ok(username)
}

Even Shorter

use std::fs;

fn read_username_from_file() -> Result<String, std::io::Error> {
    fs::read_to_string("username.txt")
}

? with Option

The ? operator also works with Option:

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    println!("{:?}", last_char_of_first_line("hello\nworld"));  // Some('o')
    println!("{:?}", last_char_of_first_line(""));               // None
}

Note: ? can only be used in functions that return Result or Option.

The Option Type

Recap

fn main() {
    let some_value: Option<i32> = Some(42);
    let no_value: Option<i32> = None;

    // Pattern matching
    match some_value {
        Some(v) => println!("Got: {}", v),
        None => println!("Nothing"),
    }

    // if let
    if let Some(v) = some_value {
        println!("Got: {}", v);
    }
}

Option Combinators

fn main() {
    let name: Option<String> = Some("Alice".to_string());
    let empty: Option<String> = None;

    // map
    let len = name.as_ref().map(|n| n.len());
    println!("Name length: {:?}", len);  // Some(5)

    // and_then (flatmap)
    let first_char = name.as_ref().and_then(|n| n.chars().next());
    println!("First char: {:?}", first_char);  // Some('A')

    // or / or_else
    let result = empty.or(Some("Default".to_string()));
    println!("Result: {:?}", result);  // Some("Default")

    // filter
    let big = Some(100).filter(|&x| x > 50);
    let small = Some(10).filter(|&x| x > 50);
    println!("Big: {:?}, Small: {:?}", big, small);  // Some(100), None

    // zip
    let name = Some("Alice");
    let age = Some(30);
    let combined = name.zip(age);
    println!("{:?}", combined);  // Some(("Alice", 30))
}

Converting Between Option and Result

fn main() {
    // Option to Result
    let opt: Option<i32> = Some(42);
    let result: Result<i32, &str> = opt.ok_or("value was None");
    println!("{:?}", result);  // Ok(42)

    let none: Option<i32> = None;
    let result: Result<i32, &str> = none.ok_or("value was None");
    println!("{:?}", result);  // Err("value was None")

    // Result to Option
    let result: Result<i32, &str> = Ok(42);
    let opt: Option<i32> = result.ok();
    println!("{:?}", opt);  // Some(42)
}

Custom Error Types

Simple Custom Error

use std::fmt;

#[derive(Debug)]
enum AppError {
    NotFound(String),
    PermissionDenied,
    ParseError(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(item) => write!(f, "Not found: {}", item),
            AppError::PermissionDenied => write!(f, "Permission denied"),
            AppError::ParseError(msg) => write!(f, "Parse error: {}", msg),
        }
    }
}

fn find_user(id: u32) -> Result<String, AppError> {
    if id == 0 {
        Err(AppError::NotFound("user".to_string()))
    } else if id == 999 {
        Err(AppError::PermissionDenied)
    } else {
        Ok(format!("User#{}", id))
    }
}

fn main() {
    match find_user(0) {
        Ok(user) => println!("Found: {}", user),
        Err(e) => println!("Error: {}", e),
    }
}

Converting Between Error Types with From

use std::fs;
use std::io;
use std::num::ParseIntError;
use std::fmt;

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

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

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

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

// Now ? automatically converts errors
fn read_number_from_file(path: &str) -> Result<i32, AppError> {
    let content = fs::read_to_string(path)?;  // io::Error -> AppError
    let number = content.trim().parse::<i32>()?;  // ParseIntError -> AppError
    Ok(number)
}

Using Box<dyn Error>

For quick prototyping or when you don't need specific error types:

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

fn read_number(path: &str) -> Result<i32, Box<dyn Error>> {
    let content = fs::read_to_string(path)?;
    let number = content.trim().parse::<i32>()?;
    Ok(number)
}

fn main() {
    match read_number("number.txt") {
        Ok(n) => println!("Got: {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

Using the thiserror Crate

For real projects, thiserror reduces boilerplate:

# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("file not found: {0}")]
    NotFound(String),

    #[error("permission denied")]
    PermissionDenied,

    #[error("IO error")]
    Io(#[from] std::io::Error),

    #[error("parse error")]
    Parse(#[from] std::num::ParseIntError),
}

This auto-generates Display, Error, and From implementations.

Error Handling Patterns

Pattern 1: Fail Fast

fn process_config(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string(path)?;
    let port: u16 = content.trim().parse()?;
    println!("Server running on port {}", port);
    Ok(())
}

fn main() {
    if let Err(e) = process_config("config.txt") {
        eprintln!("Error: {}", e);
        std::process::exit(1);
    }
}

Pattern 2: Provide Defaults

fn get_port() -> u16 {
    std::env::var("PORT")
        .ok()
        .and_then(|p| p.parse().ok())
        .unwrap_or(8080)
}

fn main() {
    let port = get_port();
    println!("Running on port {}", port);
}

Pattern 3: Collect Errors

fn parse_numbers(input: &[&str]) -> (Vec<i32>, Vec<String>) {
    let mut numbers = Vec::new();
    let mut errors = Vec::new();

    for s in input {
        match s.parse::<i32>() {
            Ok(n) => numbers.push(n),
            Err(e) => errors.push(format!("'{}': {}", s, e)),
        }
    }

    (numbers, errors)
}

fn main() {
    let input = vec!["1", "abc", "3", "xyz", "5"];
    let (numbers, errors) = parse_numbers(&input);
    println!("Parsed: {:?}", numbers);
    println!("Errors: {:?}", errors);
}

Pattern 4: Map and Chain Errors

use std::collections::HashMap;

fn get_user_age(data: &HashMap<String, String>, name: &str) -> Result<u32, String> {
    data.get(name)
        .ok_or_else(|| format!("User '{}' not found", name))
        .and_then(|age_str| {
            age_str.parse::<u32>()
                .map_err(|e| format!("Invalid age for '{}': {}", name, e))
        })
}

fn main() {
    let mut data = HashMap::new();
    data.insert("Alice".to_string(), "30".to_string());
    data.insert("Bob".to_string(), "not_a_number".to_string());

    println!("{:?}", get_user_age(&data, "Alice"));    // Ok(30)
    println!("{:?}", get_user_age(&data, "Bob"));       // Err(...)
    println!("{:?}", get_user_age(&data, "Charlie"));   // Err(...)
}

When to Use What

SituationApproach
Programming bug (index out of bounds)panic!
Expected failure (file not found)Result<T, E>
Value might not existOption<T>
Quick prototypeunwrap() / expect()
Library codeCustom error types
Application codeBox<dyn Error> or anyhow crate
Multiple error typesFrom conversions or thiserror

Practice Exercises

Exercise 1: Safe Division

Write a function that divides two numbers and returns an error for division by zero:

fn divide(a: f64, b: f64) -> Result<f64, String> {
    // Your code
}

Exercise 2: Config Parser

Write a function that reads a config file with key=value pairs and returns a HashMap. Handle missing file and malformed lines:

use std::collections::HashMap;

fn parse_config(path: &str) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
    // Your code
}

Exercise 3: Chain of Operations

Write a pipeline that reads a file of numbers (one per line), parses them, filters even numbers, and returns their sum. Propagate errors with ?.

Common Mistakes

1. Overusing unwrap

let value = some_result.unwrap();  // Ticking time bomb

Fix: Use ?, match, or unwrap_or.

2. Using ? in main

fn main() {
    let f = File::open("file.txt")?;  // ERROR: main returns ()
}

Fix: Change main's return type:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let f = File::open("file.txt")?;
    Ok(())
}

3. Ignoring Errors

let _ = File::create("file.txt");  // Error silently ignored

Fix: Handle the error or at least log it.

Key Takeaways

  • Rust uses Result<T, E> for recoverable errors and panic! for unrecoverable ones
  • The ? operator propagates errors concisely
  • Option<T> handles the absence of a value (no null)
  • Use expect() over unwrap() for better error messages
  • Custom error types with From enable automatic conversion with ?
  • thiserror and anyhow are the standard crates for error handling
  • Handle errors at the appropriate level - propagate up, handle at boundaries

Next Steps

In the next chapter, we'll explore traits and generics - Rust's system for shared behavior and type-level abstraction.

Quick Reference

// Result
let ok: Result<i32, String> = Ok(42);
let err: Result<i32, String> = Err("failed".to_string());

// ? operator (in functions returning Result)
let value = might_fail()?;

// Option
let some: Option<i32> = Some(42);
let none: Option<i32> = None;

// unwrap variants
result.unwrap();              // Panic on Err
result.expect("message");     // Panic with message
result.unwrap_or(default);    // Default on Err
result.unwrap_or_else(|e| f(e));

// Combinators
result.map(|v| v * 2);
result.and_then(|v| another_result(v));
option.ok_or("error message");

// Custom error type
#[derive(Debug)]
enum MyError { IoError(io::Error) }
impl From<io::Error> for MyError { ... }