TechLead
Lesson 8 of 28
5 min read
Rust

Error Handling in Rust

Master Rust error handling with Result, the ? operator, custom error types, thiserror, anyhow, and error handling best practices.

Rust's Error Handling Philosophy

Rust has no exceptions. Instead, it uses two mechanisms: Result<T, E> for recoverable errors and panic! for unrecoverable errors. This makes error handling explicit — you always know which functions can fail and are forced to handle errors.

When to Use What

  • Result<T, E>: File not found, network timeout, parse error — expected failures that callers should handle
  • panic!: Index out of bounds, broken invariants, unrecoverable state — bugs that shouldn't happen

panic! — Unrecoverable Errors

fn main() {
    // Explicit panic
    // panic!("Something went terribly wrong!");

    // Panics from the standard library
    let v = vec![1, 2, 3];
    // v[99]; // panic: index out of bounds

    // unwrap() panics if Result/Option is Err/None
    // let f = std::fs::read_to_string("nonexistent.txt").unwrap();

    // expect() panics with a custom message
    // let f = std::fs::read_to_string("config.toml")
    //     .expect("Failed to read config file");

    // Set RUST_BACKTRACE=1 to see the full backtrace on panic
    println!("This runs fine");
}

Result<T, E> — Recoverable Errors

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

fn read_username_from_file() -> Result {
    let result = fs::read_to_string("username.txt");

    match result {
        Ok(contents) => Ok(contents.trim().to_string()),
        Err(error) => match error.kind() {
            io::ErrorKind::NotFound => {
                // Create a default file
                fs::write("username.txt", "default_user")?;
                Ok(String::from("default_user"))
            }
            other_error => Err(io::Error::new(other_error, "Read failed")),
        },
    }
}

fn main() {
    match read_username_from_file() {
        Ok(name) => println!("Username: {name}"),
        Err(e) => eprintln!("Error: {e}"),
    }
}

The ? Operator

The ? operator is syntactic sugar for propagating errors. If the Result is Ok, it unwraps the value. If Err, it returns the error from the current function.

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

// Verbose: manual matching
fn read_file_verbose(path: &str) -> Result {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    Ok(content)
}

// Concise: with ? operator
fn read_file_concise(path: &str) -> Result {
    let content = fs::read_to_string(path)?;
    Ok(content)
}

// Even shorter — chain ? calls
fn read_first_line(path: &str) -> Result {
    Ok(fs::read_to_string(path)?
        .lines()
        .next()
        .unwrap_or("")
        .to_string())
}

// ? works with Option too!
fn first_even(numbers: &[i32]) -> Option {
    let first = numbers.first()?;      // Returns None if empty
    if first % 2 == 0 {
        Some(*first)
    } else {
        None
    }
}

fn main() -> Result<(), Box> {
    // main() can return Result too!
    let content = fs::read_to_string("Cargo.toml")?;
    println!("First 50 chars: {}", &content[..50.min(content.len())]);
    Ok(())
}

Custom Error Types

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    IoError(std::io::Error),
    ParseError(ParseIntError),
    ValidationError(String),
}

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

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

// From conversions for automatic ? conversion
impl From for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::IoError(e)
    }
}

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

fn process_file(path: &str) -> Result {
    let content = std::fs::read_to_string(path)?; // io::Error -> AppError
    let number: i32 = content.trim().parse()?;     // ParseIntError -> AppError
    if number < 0 {
        return Err(AppError::ValidationError("Must be positive".into()));
    }
    Ok(number)
}

thiserror and anyhow Crates

// thiserror: for LIBRARY code (structured errors)
// Cargo.toml: thiserror = "2"
use thiserror::Error;

#[derive(Error, Debug)]
enum DataError {
    #[error("Failed to read file: {0}")]
    Io(#[from] std::io::Error),

    #[error("Failed to parse data: {0}")]
    Parse(#[from] serde_json::Error),

    #[error("Validation failed: {field} — {message}")]
    Validation { field: String, message: String },

    #[error("Record not found: id={0}")]
    NotFound(u64),
}

// anyhow: for APPLICATION code (quick & flexible)
// Cargo.toml: anyhow = "1"
use anyhow::{Context, Result, bail, ensure};

fn load_config(path: &str) -> Result {
    let content = std::fs::read_to_string(path)
        .context("Failed to read config file")?;

    let config: Config = serde_json::from_str(&content)
        .context("Failed to parse config JSON")?;

    ensure!(config.port > 0, "Port must be positive, got {}", config.port);

    if config.name.is_empty() {
        bail!("Config name cannot be empty");
    }

    Ok(config)
}

struct Config { port: i32, name: String }

Key Takeaways

  • ✅ Use Result<T, E> for recoverable errors, panic! for bugs
  • ✅ The ? operator propagates errors concisely up the call stack
  • ✅ Implement From traits for automatic error conversion with ?
  • ✅ Use thiserror for structured library errors, anyhow for application errors
  • ✅ Always prefer returning errors over panicking in library code

Continue Learning