Error Handling

Master different error handling techniques in Rust, including `Result` type and `panic!` macro.


Custom Error Types in Rust

Rust's error handling relies heavily on the Result<T, E> type. While using standard error types like Box<dyn Error> can be convenient, creating custom error types provides more context and makes your code more robust and maintainable. This document explains how to define custom error types using enum or struct and how to implement the Error trait.

Why Custom Error Types?

  • Clarity: Custom error types clearly communicate the specific failures that can occur in your code.
  • Context: They can carry additional information about the error, aiding in debugging and recovery.
  • Maintainability: Makes the code easier to understand and refactor in the long run.
  • Pattern Matching: Enable precise error handling using pattern matching, allowing you to respond differently to distinct error conditions.

Defining Custom Error Types with enum

Using an enum is often the preferred way to define custom error types when you have a set of distinct error conditions that can occur. Each variant of the enum represents a different type of error.

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

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

impl fmt::Display for MathError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MathError::DivisionByZero => write!(f, "Division by zero"),
            MathError::NegativeSquareRoot => write!(f, "Square root of a negative number"),
            MathError::Overflow => write!(f, "Arithmetic overflow"),
        }
    }
}

impl Error for MathError {}

fn safe_divide(x: i32, y: i32) -> Result<i32, MathError> {
    if y == 0 {
        Err(MathError::DivisionByZero)
    } else {
        Ok(x / y)
    }
}

fn main() {
    match safe_divide(10, 0) {
        Ok(result) => println!("Result: {}", result),
        Err(error) => println!("Error: {}", error),
    }
} 

Explanation:

  • We define an enum called MathError with three variants: DivisionByZero, NegativeSquareRoot, and Overflow.
  • We implement the fmt::Display trait to provide a human-readable description of each error variant. This is what is printed when you use println!("{}", error).
  • We implement the Error trait for our custom error type. This is essential for using it with the Result type and for interoperability with other error handling mechanisms in Rust. The default implementation is often sufficient, requiring no explicit code within the `impl` block (i.e., `impl Error for MathError {}` is enough).
  • The safe_divide function returns a Result that can either be a successful i32 value or a MathError.

Defining Custom Error Types with struct

A struct is suitable when you need to associate specific data with an error. This allows you to store more detailed information about what went wrong.

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

#[derive(Debug)]
struct FileParseError {
    filename: String,
    line_number: usize,
    message: String,
}

impl fmt::Display for FileParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Error parsing file {}:{} - {}", self.filename, self.line_number, self.message)
    }
}

impl Error for FileParseError {}

fn parse_file(filename: &str) -> Result<(), FileParseError> {
    // Simulate a parsing error
    Err(FileParseError {
        filename: filename.to_string(),
        line_number: 42,
        message: "Invalid syntax".to_string(),
    })
}

fn main() {
    match parse_file("data.txt") {
        Ok(_) => println!("File parsed successfully!"),
        Err(error) => println!("Error: {}", error),
    }
} 

Explanation:

  • We define a struct called FileParseError that contains fields for the filename, line number, and a descriptive message.
  • The fmt::Display implementation formats the error message to include all the relevant details.
  • Again, we implement the Error trait.
  • The parse_file function simulates a parsing error and returns a FileParseError with the appropriate data.

Implementing the Error Trait

Implementing the Error trait is crucial for custom error types. It signifies that your type represents an error and allows it to be used with standard error handling mechanisms. The minimum requirement is to implement the trait itself: impl Error for MyErrorType {}. Optionally, you can override the source() method to indicate the underlying cause of the error (if applicable). The source() method returns an Option<&dyn Error>. If the current error was caused by another error, it should return Some(&underlying_error). If there is no underlying error, it should return None.

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

#[derive(Debug)]
struct UnderlyingError;

impl fmt::Display for UnderlyingError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "An underlying error occurred")
    }
}

impl Error for UnderlyingError {}


#[derive(Debug)]
struct MyError {
    message: String,
    source: Option<UnderlyingError>, // Use Option for optional source error.
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "MyError: {}", self.message)
    }
}


impl Error for MyError {
    fn source(&self) -> Option<&dyn Error> {
        self.source.as_ref().map(|e| e as &dyn Error) //Needed to avoid type mismatch
    }
}

fn main() {
    let err = MyError { message: "Something went wrong".to_string(), source: Some(UnderlyingError)};

    println!("Error: {}", err);
    if let Some(source) = err.source() {
        println!("Caused by: {}", source);
    } else {
        println!("No underlying cause for the error")
    }


} 

Explanation:

  • We define a struct UnderlyingError, to be used as the cause of the primary error.
  • We define a struct MyError that includes a message and an optional source, which is another error type.
  • In the Error trait implementation for MyError, the source() method returns Some(&self.source) if there's a source error, and None otherwise.

Combining Errors

When working with multiple sources of errors, it's often useful to create an error type that can represent any of them. This can be done with an enum:

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

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

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::ParseInt(err) => write!(f, "ParseInt error: {}", err),
        }
    }
}

impl Error for AppError {
    fn source(&self) -> Option<&dyn Error> {
        match self {
            AppError::Io(err) => Some(err),
            AppError::ParseInt(err) => Some(err),
        }
    }
}

fn read_number_from_file(filename: &str) -> Result<i32, AppError> {
    let mut file = File::open(filename).map_err(AppError::Io)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).map_err(AppError::Io)?;
    contents.trim().parse().map_err(AppError::ParseInt)
}

fn main() {
    match read_number_from_file("number.txt") {
        Ok(number) => println!("Number: {}", number),
        Err(err) => {
            println!("Error: {}", err);
            if let Some(source) = err.source() {
                println!("Caused by: {}", source);
            }
        }
    }
} 

Explanation:

  • The AppError enum encapsulates both io::Error and std::num::ParseIntError.
  • The source() method returns the underlying error depending on which variant is active.
  • The read_number_from_file function uses the ? operator to automatically convert errors from File::open and parse into AppError variants.
Note: Using the thiserror crate can significantly simplify the process of defining custom error types and implementing the Error trait. It provides derive macros that automatically generate the necessary boilerplate code.