Skip to content

Rust Error Handling

Overview

Rust adopts a unique approach to error handling, using the type system to handle errors rather than exception mechanisms. This chapter will learn about error handling patterns in Rust, including the Result and Option types, as well as error propagation and custom error types.

🎯 Rust Error Handling Philosophy

Error Classification

rust
// Rust categorizes errors into two types:
// 1. Recoverable Errors - use Result<T, E>
// 2. Unrecoverable Errors - use panic!

fn error_classification() {
    // Recoverable error example
    let result = std::fs::read_to_string("maybe_exists.txt");
    match result {
        Ok(content) => println!("File content: {}", content),
        Err(error) => println!("Failed to read file: {}", error),
    }

    // Unrecoverable error example
    let numbers = vec![1, 2, 3];
    // let index = numbers[10]; // This will panic!

    // Manually trigger panic
    if numbers.is_empty() {
        panic!("Vector cannot be empty!");
    }
}

📦 Option Type

Option Basics

rust
fn option_basics() {
    // Option represents a value that may or may not exist
    let some_number = Some(5);
    let some_string = Some("string");
    let absent_number: Option<i32> = None;

    println!("Has value: {:?}", some_number);
    println!("No value: {:?}", absent_number);

    // Use match to handle Option
    let x = Some(5);
    match x {
        Some(value) => println!("Value is: {}", value),
        None => println!("No value"),
    }

    // Use if let to simplify
    if let Some(value) = some_number {
        println!("Found value: {}", value);
    }
}

Common Option Methods

rust
fn option_methods() {
    let x = Some(2);
    let y: Option<i32> = None;

    // is_some() and is_none()
    println!("x has value? {}", x.is_some());
    println!("y has no value? {}", y.is_none());

    // unwrap() - dangerous method, may panic
    // let value = y.unwrap(); // This will panic!

    // unwrap_or() - provide default value
    let default_value = y.unwrap_or(0);
    println!("Default value: {}", default_value);

    // unwrap_or_else() - use closure to compute default value
    let computed_default = y.unwrap_or_else(|| {
        println!("Computing default value");
        42
    });
    println!("Computed default: {}", computed_default);

    // expect() - unwrap with custom error message
    let value = x.expect("x should have a value");
    println!("Expected value: {}", value);

    // map() - transform value inside Option
    let doubled = x.map(|v| v * 2);
    println!("Doubled: {:?}", doubled);

    // and_then() - chain operations
    let result = x.and_then(|v| {
        if v > 0 {
            Some(v * 10)
        } else {
            None
        }
    });
    println!("Chained result: {:?}", result);

    // filter() - conditional filtering
    let filtered = x.filter(|&v| v > 1);
    println!("Filtered result: {:?}", filtered);
}

Real-World Option Applications

rust
fn find_user_by_id(id: u32) -> Option<User> {
    let users = vec![
        User { id: 1, name: "Alice".to_string() },
        User { id: 2, name: "Bob".to_string() },
        User { id: 3, name: "Charlie".to_string() },
    ];

    users.into_iter().find(|user| user.id == id)
}

#[derive(Debug)]
struct User {
    id: u32,
    name: String,
}

fn option_real_world() {
    // Find user
    match find_user_by_id(2) {
        Some(user) => println!("Found user: {:?}", user),
        None => println!("User does not exist"),
    }

    // Parse string
    let number_str = "42";
    let parsed = number_str.parse::<i32>().ok(); // Result to Option
    println!("Parse result: {:?}", parsed);

    // Array access
    let numbers = vec![1, 2, 3, 4, 5];
    let first = numbers.get(0);
    let out_of_bounds = numbers.get(10);
    println!("First element: {:?}", first);
    println!("Out of bounds: {:?}", out_of_bounds);
}

⚠️ Result Type

Result Basics

rust
// Result<T, E> represents an operation that may succeed or fail
fn divide(dividend: f64, divisor: f64) -> Result<f64, String> {
    if divisor == 0.0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(dividend / divisor)
    }
}

fn result_basics() {
    let success_result = divide(10.0, 2.0);
    let error_result = divide(10.0, 0.0);

    // Use match to handle Result
    match success_result {
        Ok(value) => println!("Result: {}", value),
        Err(error) => println!("Error: {}", error),
    }

    match error_result {
        Ok(value) => println!("Result: {}", value),
        Err(error) => println!("Error: {}", error),
    }

    // Use if let to simplify
    if let Ok(value) = divide(20.0, 4.0) {
        println!("Division result: {}", value);
    }

    if let Err(error) = divide(10.0, 0.0) {
        println!("Division error: {}", error);
    }
}

Common Result Methods

rust
fn result_methods() {
    let success: Result<i32, &str> = Ok(42);
    let failure: Result<i32, &str> = Err("Something went wrong");

    // is_ok() and is_err()
    println!("Success? {}", success.is_ok());
    println!("Failure? {}", failure.is_err());

    // unwrap() and expect()
    let value = success.unwrap();
    println!("Unwrapped value: {}", value);

    // let error_value = failure.unwrap(); // This will panic!

    let expected_value = success.expect("Should succeed");
    println!("Expected value: {}", expected_value);

    // unwrap_or() and unwrap_or_else()
    let default_value = failure.unwrap_or(0);
    println!("Default value: {}", default_value);

    let computed_value = failure.unwrap_or_else(|error| {
        println!("Handling error: {}", error);
        -1
    });
    println!("Computed value: {}", computed_value);

    // map() and map_err()
    let doubled = success.map(|v| v * 2);
    println!("Doubled result: {:?}", doubled);

    let mapped_error = failure.map_err(|e| format!("Error: {}", e));
    println!("Mapped error: {:?}", mapped_error);

    // and_then() - chain operations
    let chained = success.and_then(|v| {
        if v > 0 {
            Ok(v.to_string())
        } else {
            Err("Value must be greater than 0")
        }
    });
    println!("Chained result: {:?}", chained);
}

🔄 Error Propagation

The ? Operator

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

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

// Equivalent match version
fn read_username_from_file_verbose() -> Result<String, io::Error> {
    let f = File::open("username.txt");

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

    let mut s = String::new();

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

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

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

Error Propagation Chaining

rust
fn error_propagation_chain() -> Result<i32, Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("numbers.txt")?;
    let number: i32 = content.trim().parse()?;
    let result = divide_by_two(number)?;
    Ok(result)
}

fn divide_by_two(n: i32) -> Result<i32, String> {
    if n % 2 != 0 {
        Err("Number must be even".to_string())
    } else {
        Ok(n / 2)
    }
}

fn demonstration_error_propagation() {
    match error_propagation_chain() {
        Ok(result) => println!("Final result: {}", result),
        Err(error) => println!("Error: {}", error),
    }
}

🛠️ Custom Error Types

Simple Custom Error

rust
#[derive(Debug)]
enum CalculatorError {
    DivisionByZero,
    InvalidInput,
    Overflow,
}

impl std::fmt::Display for CalculatorError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            CalculatorError::DivisionByZero => write!(f, "Cannot divide by zero"),
            CalculatorError::InvalidInput => write!(f, "Invalid input"),
            CalculatorError::Overflow => write!(f, "Numeric overflow"),
        }
    }
}

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

fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, CalculatorError> {
    if divisor == 0 {
        return Err(CalculatorError::DivisionByZero);
    }

    match dividend.checked_div(divisor) {
        Some(result) => Ok(result),
        None => Err(CalculatorError::Overflow),
    }
}

fn custom_error_example() {
    let operations = vec![
        (10, 2),
        (10, 0),
        (i32::MIN, -1),
    ];

    for (a, b) in operations {
        match safe_divide(a, b) {
            Ok(result) => println!("{} / {} = {}", a, b, result),
            Err(error) => println!("{} / {} failed: {}", a, b, error),
        }
    }
}

Complex Error Types

rust
use std::fmt;

#[derive(Debug)]
struct ParseError {
    input: String,
    kind: ParseErrorKind,
}

#[derive(Debug)]
enum ParseErrorKind {
    EmptyInput,
    InvalidCharacter(char),
    NumberTooLarge,
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Error parsing '{}': ", self.input)?;
        match &self.kind {
            ParseErrorKind::EmptyInput => write!(f, "Input is empty"),
            ParseErrorKind::InvalidCharacter(c) => write!(f, "Invalid character '{}'", c),
            ParseErrorKind::NumberTooLarge => write!(f, "Number is too large"),
        }
    }
}

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

fn parse_positive_number(input: &str) -> Result<u32, ParseError> {
    if input.is_empty() {
        return Err(ParseError {
            input: input.to_string(),
            kind: ParseErrorKind::EmptyInput,
        });
    }

    for ch in input.chars() {
        if !ch.is_ascii_digit() {
            return Err(ParseError {
                input: input.to_string(),
                kind: ParseErrorKind::InvalidCharacter(ch),
            });
        }
    }

    match input.parse::<u32>() {
        Ok(number) => Ok(number),
        Err(_) => Err(ParseError {
            input: input.to_string(),
            kind: ParseErrorKind::NumberTooLarge,
        }),
    }
}

fn complex_error_example() {
    let test_inputs = vec!["123", "", "12a", "999999999999999999999"];

    for input in test_inputs {
        match parse_positive_number(input) {
            Ok(number) => println!("Parse successful: {} -> {}", input, number),
            Err(error) => println!("Parse failed: {}", error),
        }
    }
}

Error Conversion and Compatibility

rust
use std::num::ParseIntError;

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

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

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

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

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

fn process_file(filename: &str) -> Result<i32, AppError> {
    let content = std::fs::read_to_string(filename)?; // Auto-convert io::Error
    let number: i32 = content.trim().parse()?; // Auto-convert ParseIntError

    if number < 0 {
        return Err(AppError::Custom("Number cannot be negative".to_string()));
    }

    Ok(number * 2)
}

fn error_conversion_example() {
    match process_file("number.txt") {
        Ok(result) => println!("Processing result: {}", result),
        Err(error) => println!("Processing failed: {}", error),
    }
}

🎭 Advanced Error Handling Patterns

Handling Multiple Error Types

rust
use std::collections::HashMap;

type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

struct UserManager {
    users: HashMap<u32, User>,
}

impl UserManager {
    fn new() -> Self {
        Self {
            users: HashMap::new(),
        }
    }

    fn add_user(&mut self, id: u32, name: String) -> Result<()> {
        if self.users.contains_key(&id) {
            return Err("User ID already exists".into());
        }

        if name.is_empty() {
            return Err("User name cannot be empty".into());
        }

        self.users.insert(id, User { id, name });
        Ok(())
    }

    fn get_user(&self, id: u32) -> Result<&User> {
        self.users.get(&id).ok_or_else(|| "User does not exist".into())
    }

    fn save_to_file(&self, filename: &str) -> Result<()> {
        let json = serde_json::to_string(&self.users)?;
        std::fs::write(filename, json)?;
        Ok(())
    }

    fn load_from_file(&mut self, filename: &str) -> Result<()> {
        let content = std::fs::read_to_string(filename)?;
        self.users = serde_json::from_str(&content)?;
        Ok(())
    }
}

fn advanced_error_handling() {
    let mut manager = UserManager::new();

    // Chained error handling
    let result = manager
        .add_user(1, "Alice".to_string())
        .and_then(|_| manager.add_user(2, "Bob".to_string()))
        .and_then(|_| manager.save_to_file("users.json"));

    match result {
        Ok(_) => println!("Operation successful"),
        Err(error) => println!("Operation failed: {}", error),
    }
}

Recovery Strategies

rust
fn retry_with_backoff<F, T, E>(mut operation: F, max_retries: u32) -> Result<T, E>
where
    F: FnMut() -> Result<T, E>,
    E: std::fmt::Debug,
{
    let mut delay = std::time::Duration::from_millis(100);

    for attempt in 0..=max_retries {
        match operation() {
            Ok(result) => return Ok(result),
            Err(error) => {
                if attempt == max_retries {
                    return Err(error);
                }

                println!("Attempt {} failed: {:?}, retrying in {}ms", attempt + 1, error, delay.as_millis());
                std::thread::sleep(delay);
                delay *= 2; // Exponential backoff
            }
        }
    }

    unreachable!()
}

fn unreliable_operation() -> Result<String, String> {
    use rand::Rng;
    let mut rng = rand::thread_rng();

    if rng.gen_bool(0.7) { // 70% failure rate
        Err("Network connection failed".to_string())
    } else {
        Ok("Operation successful".to_string())
    }
}

fn recovery_strategy_example() {
    match retry_with_backoff(unreliable_operation, 3) {
        Ok(result) => println!("Finally succeeded: {}", result),
        Err(error) => println!("Finally failed: {}", error),
    }
}

📝 Chapter Summary

Through this chapter, you should have mastered:

Basic Error Handling

  • ✅ Using Option and Result types
  • ✅ Various error handling methods
  • ✅ Error propagation with ? operator
  • ✅ Choosing between match and if let

Advanced Error Handling

  • ✅ Designing custom error types
  • ✅ Error conversion and compatibility
  • ✅ Unified handling of multiple error types
  • ✅ Error recovery strategies

Best Practices

  1. Prefer Result over panic
  2. Design clear error type hierarchies
  3. Provide meaningful error messages
  4. Use ? operator appropriately
  5. Consider error recoverability

Continue Learning: Next Chapter - Rust Generics and Traits

Content is for learning and research only.