1. rust
  2. /advanced
  3. /error-handling

Error Handling

Rust's error handling is built around explicit error types rather than exceptions. This approach makes errors visible in function signatures and forces developers to handle them explicitly, leading to more robust and maintainable code.

Error Handling Philosophy

Rust categorizes failures into two main types:

  • Recoverable errors: Represented by Result<T, E>
  • Unrecoverable errors: Handled by panicking with panic!
// Recoverable error - function returns Result
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

// Unrecoverable error - panics
fn access_array(arr: &[i32], index: usize) -> i32 {
    if index >= arr.len() {
        panic!("Index {} out of bounds for array of length {}", index, arr.len());
    }
    arr[index]
}

fn main() {
    // Handle recoverable error
    match divide(10.0, 2.0) {
        Ok(result) => println!("Result: {}", result),
        Err(error) => println!("Error: {}", error),
    }
    
    // This would panic
    let numbers = [1, 2, 3];
    // access_array(&numbers, 5); // Uncomment to see panic
}

The Result Type

Result<T, E> is an enum representing either success (Ok(T)) or failure (Err(E)):

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

Basic Result Usage

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

fn open_file(filename: &str) -> Result<File, std::io::Error> {
    File::open(filename)
}

fn main() {
    match open_file("hello.txt") {
        Ok(file) => println!("File opened successfully: {:?}", file),
        Err(error) => match error.kind() {
            ErrorKind::NotFound => println!("File not found: {}", error),
            ErrorKind::PermissionDenied => println!("Permission denied: {}", error),
            other_error => println!("Problem opening file: {:?}", other_error),
        },
    }
}

Result Methods

fn demonstrate_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 is_ok: {}", success.is_ok());
    println!("Failure is_err: {}", failure.is_err());
    
    // unwrap_or() - provide default for error
    println!("Success or default: {}", success.unwrap_or(0));
    println!("Failure or default: {}", failure.unwrap_or(0));
    
    // unwrap_or_else() - compute default lazily
    let computed = failure.unwrap_or_else(|err| {
        println!("Computing default due to error: {}", err);
        -1
    });
    println!("Computed default: {}", computed);
    
    // map() - transform success value
    let doubled = success.map(|x| x * 2);
    println!("Doubled: {:?}", doubled);
    
    // map_err() - transform error value
    let better_error = failure.map_err(|err| format!("Error: {}", err));
    println!("Better error: {:?}", better_error);
}

Error Propagation with ?

The ? operator provides a concise way to propagate errors:

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

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

// Equivalent verbose version
fn read_username_from_file_verbose() -> Result<String, io::Error> {
    let username_file = File::open("username.txt");
    let mut username_file = match username_file {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    
    let mut username = String::new();
    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

// Chain multiple operations
fn read_and_process_file() -> Result<usize, io::Error> {
    let contents = std::fs::read_to_string("data.txt")?;
    let processed = contents.trim().to_uppercase();
    Ok(processed.len())
}

fn main() {
    match read_username_from_file() {
        Ok(username) => println!("Username: {}", username),
        Err(error) => println!("Failed to read username: {}", error),
    }
}

Custom Error Types

Create custom error types for domain-specific errors:

use std::fmt;

#[derive(Debug)]
enum MathError {
    DivisionByZero,
    NegativeSquareRoot,
    InvalidInput(String),
}

impl fmt::Display for MathError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MathError::DivisionByZero => write!(f, "Cannot divide by zero"),
            MathError::NegativeSquareRoot => write!(f, "Cannot take square root of negative number"),
            MathError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
        }
    }
}

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

fn divide(a: f64, b: f64) -> Result<f64, MathError> {
    if b == 0.0 {
        Err(MathError::DivisionByZero)
    } else {
        Ok(a / b)
    }
}

fn sqrt(x: f64) -> Result<f64, MathError> {
    if x < 0.0 {
        Err(MathError::NegativeSquareRoot)
    } else {
        Ok(x.sqrt())
    }
}

fn calculate(input: &str) -> Result<f64, MathError> {
    let num: f64 = input.parse()
        .map_err(|_| MathError::InvalidInput(input.to_string()))?;
    
    let divided = divide(num, 2.0)?;
    let result = sqrt(divided)?;
    Ok(result)
}

fn main() {
    let test_cases = vec!["16", "0", "-4", "abc"];
    
    for input in test_cases {
        match calculate(input) {
            Ok(result) => println!("calculate({}) = {}", input, result),
            Err(e) => println!("calculate({}) failed: {}", input, e),
        }
    }
}

Error Trait and Error Chaining

The std::error::Error trait provides a standard interface for errors:

use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct ParseError {
    message: String,
    line: usize,
    column: usize,
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Parse error at line {}, column {}: {}", 
               self.line, self.column, self.message)
    }
}

impl Error for ParseError {}

#[derive(Debug)]
struct ValidationError {
    field: String,
    value: String,
    reason: String,
}

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Validation failed for field '{}' with value '{}': {}", 
               self.field, self.value, self.reason)
    }
}

impl Error for ValidationError {}

// Composite error type
#[derive(Debug)]
enum ProcessingError {
    Parse(ParseError),
    Validation(ValidationError),
    Io(std::io::Error),
}

impl fmt::Display for ProcessingError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ProcessingError::Parse(e) => write!(f, "Processing failed due to parse error: {}", e),
            ProcessingError::Validation(e) => write!(f, "Processing failed due to validation error: {}", e),
            ProcessingError::Io(e) => write!(f, "Processing failed due to I/O error: {}", e),
        }
    }
}

impl Error for ProcessingError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ProcessingError::Parse(e) => Some(e),
            ProcessingError::Validation(e) => Some(e),
            ProcessingError::Io(e) => Some(e),
        }
    }
}

// Conversion implementations for automatic error conversion with ?
impl From<ParseError> for ProcessingError {
    fn from(error: ParseError) -> Self {
        ProcessingError::Parse(error)
    }
}

impl From<ValidationError> for ProcessingError {
    fn from(error: ValidationError) -> Self {
        ProcessingError::Validation(error)
    }
}

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

fn parse_config(content: &str) -> Result<String, ParseError> {
    if content.is_empty() {
        return Err(ParseError {
            message: "Empty input".to_string(),
            line: 1,
            column: 1,
        });
    }
    Ok(content.to_string())
}

fn validate_config(config: &str) -> Result<(), ValidationError> {
    if config.len() < 10 {
        return Err(ValidationError {
            field: "config".to_string(),
            value: config.to_string(),
            reason: "Config too short".to_string(),
        });
    }
    Ok(())
}

fn process_config_file(filename: &str) -> Result<String, ProcessingError> {
    let content = std::fs::read_to_string(filename)?; // io::Error -> ProcessingError
    let config = parse_config(&content)?; // ParseError -> ProcessingError
    validate_config(&config)?; // ValidationError -> ProcessingError
    Ok(config)
}

fn main() {
    match process_config_file("config.txt") {
        Ok(config) => println!("Processed config: {}", config),
        Err(e) => {
            println!("Error: {}", e);
            
            // Print error chain
            let mut source = e.source();
            while let Some(err) = source {
                println!("Caused by: {}", err);
                source = err.source();
            }
        }
    }
}

Error Handling Patterns

Early Return Pattern

fn validate_user_input(input: &str) -> Result<u32, String> {
    if input.is_empty() {
        return Err("Input cannot be empty".to_string());
    }
    
    if input.len() > 10 {
        return Err("Input too long".to_string());
    }
    
    let number: u32 = input.parse()
        .map_err(|_| "Invalid number format".to_string())?;
    
    if number == 0 {
        return Err("Number cannot be zero".to_string());
    }
    
    Ok(number)
}

Collecting Results

fn parse_numbers(strings: Vec<&str>) -> Result<Vec<i32>, std::num::ParseIntError> {
    strings.into_iter()
        .map(|s| s.parse::<i32>())
        .collect() // Stops at first error
}

fn parse_numbers_collect_errors(strings: Vec<&str>) -> (Vec<i32>, Vec<std::num::ParseIntError>) {
    strings.into_iter()
        .map(|s| s.parse::<i32>())
        .partition_map(|result| match result {
            Ok(num) => itertools::Either::Left(num),
            Err(err) => itertools::Either::Right(err),
        })
}

// Alternative without itertools
fn parse_numbers_with_errors(strings: Vec<&str>) -> (Vec<i32>, Vec<String>) {
    let mut numbers = Vec::new();
    let mut errors = Vec::new();
    
    for s in strings {
        match s.parse::<i32>() {
            Ok(num) => numbers.push(num),
            Err(e) => errors.push(format!("Failed to parse '{}': {}", s, e)),
        }
    }
    
    (numbers, errors)
}

fn main() {
    let inputs = vec!["1", "2", "abc", "4", "def"];
    
    match parse_numbers(inputs.clone()) {
        Ok(numbers) => println!("All numbers: {:?}", numbers),
        Err(e) => println!("Failed to parse all numbers: {}", e),
    }
    
    let (numbers, errors) = parse_numbers_with_errors(inputs);
    println!("Successfully parsed: {:?}", numbers);
    println!("Errors: {:?}", errors);
}

Error Recovery

fn read_config_with_fallback(primary_path: &str, fallback_path: &str) -> Result<String, std::io::Error> {
    match std::fs::read_to_string(primary_path) {
        Ok(content) => Ok(content),
        Err(_) => {
            println!("Primary config not found, trying fallback...");
            std::fs::read_to_string(fallback_path)
        }
    }
}

fn parse_with_default(input: &str) -> i32 {
    input.parse().unwrap_or_else(|_| {
        println!("Failed to parse '{}', using default value", input);
        0
    })
}

anyhow and thiserror Crates

While not part of the standard library, these crates are commonly used for error handling:

Using anyhow for Application Errors

// In Cargo.toml: anyhow = "1.0"
use anyhow::{Context, Result, anyhow};

fn read_user_from_file(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read user file from {}", path))?
        .parse()
        .context("Failed to parse user data")
}

fn process_user() -> Result<()> {
    let user = read_user_from_file("user.json")?;
    
    if user.is_empty() {
        return Err(anyhow!("User data is empty"));
    }
    
    println!("Processing user: {}", user);
    Ok(())
}

Using thiserror for Library Errors

// In Cargo.toml: thiserror = "1.0"
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("Data store disconnected")]
    Disconnect(#[from] std::io::Error),
    
    #[error("The data for key `{key}` is not available")]
    Redaction { key: String },
    
    #[error("Invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader { expected: String, found: String },
    
    #[error("Unknown data store error")]
    Unknown,
}

Testing Error Conditions

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_divide_by_zero() {
        match divide(10.0, 0.0) {
            Err(MathError::DivisionByZero) => (), // Expected
            Ok(_) => panic!("Expected division by zero error"),
            Err(other) => panic!("Expected division by zero, got {:?}", other),
        }
    }
    
    #[test]
    fn test_successful_calculation() {
        let result = calculate("16").unwrap();
        assert!((result - 2.83).abs() < 0.01); // sqrt(16/2) ≈ 2.83
    }
    
    #[test]
    fn test_error_chain() {
        let error = ProcessingError::Parse(ParseError {
            message: "test error".to_string(),
            line: 1,
            column: 1,
        });
        
        assert!(error.source().is_some());
    }
    
    // Test that ? operator works correctly
    #[test]
    fn test_error_propagation() {
        fn might_fail() -> Result<i32, &'static str> {
            Err("something went wrong")
        }
        
        fn calls_might_fail() -> Result<i32, &'static str> {
            let value = might_fail()?;
            Ok(value * 2)
        }
        
        assert!(calls_might_fail().is_err());
    }
}

Best Practices

1. Use Result for Recoverable Errors

// Good: Recoverable error with Result
fn parse_age(input: &str) -> Result<u8, String> {
    let age: u8 = input.parse()
        .map_err(|_| format!("Invalid age format: '{}'", input))?;
    
    if age > 150 {
        return Err("Age cannot be greater than 150".to_string());
    }
    
    Ok(age)
}

// Avoid: Panicking for recoverable errors
fn parse_age_bad(input: &str) -> u8 {
    input.parse().expect("Invalid age") // Don't panic for user input!
}

2. Provide Informative Error Messages

// Good: Descriptive error messages
#[derive(Debug)]
enum ConfigError {
    MissingField { field: String, section: String },
    InvalidValue { field: String, value: String, expected: String },
    FileNotFound { path: String },
}

// Avoid: Generic error messages
#[derive(Debug)]
enum BadConfigError {
    Error,
    BadValue,
    NotFound,
}

3. Use ? for Error Propagation

// Good: Clean error propagation
fn load_and_parse_config(path: &str) -> Result<Config, ConfigError> {
    let contents = std::fs::read_to_string(path)
        .map_err(|_| ConfigError::FileNotFound { path: path.to_string() })?;
    
    let config = parse_config(&contents)?;
    validate_config(&config)?;
    Ok(config)
}

// Avoid: Manual error handling when ? works
fn load_and_parse_config_manual(path: &str) -> Result<Config, ConfigError> {
    let contents = match std::fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(_) => return Err(ConfigError::FileNotFound { path: path.to_string() }),
    };
    
    let config = match parse_config(&contents) {
        Ok(config) => config,
        Err(e) => return Err(e),
    };
    
    match validate_config(&config) {
        Ok(()) => Ok(config),
        Err(e) => Err(e),
    }
}

4. Implement From for Error Conversion

// Enable automatic conversion with ?
impl From<std::io::Error> for ConfigError {
    fn from(error: std::io::Error) -> Self {
        ConfigError::FileNotFound { 
            path: format!("I/O error: {}", error) 
        }
    }
}

impl From<serde_json::Error> for ConfigError {
    fn from(error: serde_json::Error) -> Self {
        ConfigError::InvalidValue {
            field: "json".to_string(),
            value: error.to_string(),
            expected: "valid JSON".to_string(),
        }
    }
}

5. Consider Using Library Crates for Complex Error Handling

// For applications: anyhow provides flexible error handling
use anyhow::{Context, Result};

fn application_function() -> Result<()> {
    do_something().context("Failed to do something")?;
    do_something_else().context("Failed to do something else")?;
    Ok(())
}

// For libraries: thiserror provides structured error types
use thiserror::Error;

#[derive(Error, Debug)]
pub enum LibraryError {
    #[error("Invalid input: {0}")]
    InvalidInput(String),
    
    #[error("Operation failed")]
    OperationFailed(#[from] std::io::Error),
}

Rust's error handling system encourages explicit handling of failure cases, leading to more robust code. By using Result types, custom error enums, and the ? operator, you can build applications that gracefully handle errors and provide meaningful feedback to users and developers.