Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 15: Error Handling with Result

Reliable software requires robust error handling. In C, error management often relies on conventions like special return values (e.g., -1, NULL) or global variables (e.g., errno). These methods require discipline, as the compiler does not enforce error checks, making it easy to overlook potential failures. C++ introduced exceptions, offering a different model but with its own complexities.

Rust tackles error handling differently, integrating it into the type system. It distinguishes between errors that are expected and potentially recoverable, and those that signify critical, unrecoverable problems (often bugs). This distinction is enforced by the compiler, guiding developers to acknowledge and handle potential failures appropriately.


15.1 Recoverable vs. Unrecoverable Errors

Rust classifies runtime errors into two primary categories:

  1. Recoverable Errors: These are expected issues a program might encounter during normal operation, such as failing to open a file, network timeouts, or invalid user input. The program can typically handle these errors gracefully, perhaps by retrying, using a default value, or reporting the issue. Rust uses the generic Result<T, E> enum to represent outcomes that might be successful (Ok(T)) or result in a recoverable error (Err(E)).
  2. Unrecoverable Errors: These represent serious issues, usually programming errors (bugs), from which the program cannot reliably continue. Examples include accessing an array out of bounds, division by zero, or failing assertions about program state. Continuing execution could lead to undefined behavior, data corruption, or security vulnerabilities. Rust uses the panic! macro to signal unrecoverable errors. By default, a panic unwinds the stack of the current thread and terminates it. If this is the main thread, the program exits.

This explicit, type-system-based distinction contrasts sharply with C. In C, whether a -1 return value signifies a recoverable file-not-found error or an unrecoverable null pointer access often depends solely on documentation and programmer discipline. Rust’s Result forces the programmer to consider recoverable errors at compile time. Panics are reserved for situations where proceeding is deemed impossible or unsafe, turning potential C undefined behavior (like out-of-bounds access) into a defined program termination.


15.2 The Result<T, E> Enum for Recoverable Errors

For most anticipated runtime failures, Rust employs the Result<T, E> enum.

15.2.1 Definition of Result

The Result enum is defined in the standard library:

enum Result<T, E> {
    Ok(T), // Represents success and contains a value of type T.
    Err(E), // Represents error and contains an error value of type E.
}
  • T: The type of the value returned in the success case (Ok variant).
  • E: The type of the error value returned in the failure case (Err variant).

A function signature like fn might_fail() -> Result<Data, ErrorInfo> clearly communicates that the function can either succeed, returning a Data value wrapped in Ok, or fail, returning an ErrorInfo value wrapped in Err. The compiler requires the caller to handle both possibilities, preventing the common C pitfall of accidentally ignoring an error return code.

15.2.2 Handling Result Values

The most fundamental way to handle a Result is with a match expression:

use std::fs::{File, OpenOptions};
use std::io::{self, Write};

fn main() {
    // Ensure a dummy file exists with some minimal content
    let _ = OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open("my_file.txt")
        .and_then(|mut f| f.write_all(b"Hello, world!"));

    let file_result = File::open("my_file.txt"); // Returns Result<File, io::Error>

    let file_handle = match file_result {
        Ok(file) => {
            println!("File opened successfully.");
            file // The value inside Ok is extracted
        }
        Err(error) => {
            // Handle the error based on its kind
            match error.kind() {
                io::ErrorKind::NotFound => {
                    eprintln!("Error: File not found: {}", error);
                    // Decide what to do: maybe return, maybe panic, maybe create
                    // the file. For this example, we panic. In real code, avoid
                    // panic for recoverable errors.
                    panic!("File not found, cannot continue.");
                }
                other_error => {
                    eprintln!("Error opening file: {}", other_error);
                    panic!("An unexpected I/O error occurred.");
                }
            }
        }
    };

    // If we didn't panic, we can use file_handle here...
    println!("Continuing execution with file handle (if not panicked).");
    // file_handle goes out of scope here, and its destructor closes the file.
}

This match forces explicit consideration of both Ok and Err. The nested match demonstrates handling specific error kinds within the io::Error type.

Alternatively, you can check the state using methods like is_ok() and is_err() before attempting to extract the value (often via unwrap, discussed later, though careful handling is preferred):

use std::fs::File;
use std::io;
fn main() {
    let file_result = File::open("another_file.txt");

    if file_result.is_ok() {
        println!("File open seems ok.");
        // Proceed, likely unwrapping or matching to get the value
        let _file = file_result.unwrap();
    } else if file_result.is_err() {
        let error = file_result.err().unwrap(); // Get the error value
        eprintln!("Failed to open file: {}", error);
        // Handle the error appropriately
    }
}

While is_ok() and is_err() are simple checks, match or combinators are generally preferred for robust handling as they ensure both cases (Ok and Err) are considered together.

15.2.3 Option<T> vs. Result<T, E>

Rust also provides the Option<T> enum for representing optional values:

enum Option<T> {
    Some(T), // Represents the presence of a value of type T.
    None,    // Represents the absence of a value.
}

The distinction is crucial:

  • Use Option<T> when a value might be absent, and this absence is a normal, expected outcome, not an error. Example: Searching a hash map might yield Some(value) or None if the key isn’t present. None is not a failure; it’s a valid result.
  • Use Result<T, E> when an operation could fail, and you need to convey why it failed. The Err(E) variant carries information about the error condition. Example: Opening a file might fail due to permissions (Err(io::Error)), which is distinct from successfully determining a file doesn’t contain a specific configuration key (Ok(None) using an Option inside Result).

15.2.4 Combinators for Result

While match is explicit, it can be verbose for chained operations. Result provides methods called combinators that allow transforming or chaining Result values more concisely. Common combinators include:

  • map: Transforms the Ok value, leaving Err untouched.
  • map_err: Transforms the Err value, leaving Ok untouched.
  • and_then: If Ok, calls a closure with the value. The closure must return a new Result. If Err, propagates the Err. Useful for sequencing fallible operations.
  • or_else: If Err, calls a closure with the error. The closure must return a new Result. If Ok, propagates the Ok. Useful for trying alternative operations on failure.
  • unwrap_or: Returns the Ok value or a provided default value if Err.
  • unwrap_or_else: Returns the Ok value or computes a default value from a closure if Err.

Example using and_then and map:

use std::num::ParseIntError;

fn multiply_combinators(first_str: &str, second_str: &str) ->
    Result<i32, ParseIntError> {
    first_str.parse::<i32>().and_then(|first_number| {
        second_str.parse::<i32>().map(|second_number| {
            first_number * second_number
        })
    })
    // If first parse fails, and_then short-circuits, returning the Err.
    // If first succeeds, second parse is attempted.
    // If second parse fails, map propagates the Err.
    // If second succeeds, map applies the closure (multiplication) to the Ok value.
}

fn main() {
    println!("Comb. Multiply '10' and '2': {:?}", multiply_combinators("10", "2"));
    println!("Comb. Multiply 'x' and 'y': {:?}", multiply_combinators("x", "y"));
}

Many other useful combinators exist. For a comprehensive list, refer to the official std::result::Result documentation.

15.2.5 The unwrap and expect Methods (Use with Caution)

Result<T, E> (and Option<T>) have methods that provide convenient shortcuts but can cause panics:

  • unwrap(): Returns the value inside Ok. If the Result is Err, it panics.
  • expect(message: &str): Similar to unwrap, but panics with the provided custom message if the Result is Err.
fn main() {
    let result: Result<i32, &str> = Err("Operation failed");

    // let value = result.unwrap(); // Panics with a generic message
    let value = result.expect("Critical operation failed unexpectedly!");
    // Panics with specific message
    println!("Value: {}", value); // This line is never reached
}

When to use unwrap or expect:

  1. Prototypes/Examples: Quick and dirty code where explicit error handling is deferred.
  2. Tests: Asserting that an operation must succeed in a test scenario.
  3. Logical Guarantees: When program logic ensures the Result cannot be Err (or Option cannot be None). For example, accessing a default value inserted into a map just before.

Avoid unwrap and expect in production code where failure is a realistic possibility. An unexpected panic is usually less desirable and harder to debug than a properly handled Err. Prefer match, combinators, or the ? operator for robust error handling.


15.3 Propagating Errors with the ? Operator

Handling errors from multiple sequential operations using match or combinators can still become nested or verbose. Rust provides the question mark operator (?) as syntactic sugar for the common pattern of error propagation.

15.3.1 How ? Works

When applied to an expression returning Result<T, E>, the ? operator behaves as follows:

  1. If the Result is Ok(value), it unwraps the Result and yields the value for the rest of the expression.
  2. If the Result is Err(error), it immediately returns the Err(error) from the enclosing function.

Crucially, the ? operator can only be used inside functions that themselves return a Result (or Option, or another type implementing specific traits). The error type (E) of the Result being questioned must be convertible into the error type returned by the enclosing function (via the From trait, discussed later).

Consider reading a username from a file, simplified using ?:

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

// This function must return Result because it uses '?'
fn read_username_from_file() -> Result<String, io::Error> {
    // File::open returns Result<File, io::Error>.
    // If Ok, the File handle is assigned to `file`.
    // If Err, the io::Error is returned immediately from read_username_from_file.
    let mut file = File::open("username.txt")?;

    let mut s = String::new();
    // file.read_to_string returns Result<usize, io::Error>.
    // If Ok, the number of bytes read (usize) is discarded, and `s` contains content
    // If Err, the io::Error is returned immediately from read_username_from_file.
    file.read_to_string(&mut s)?;

    // If both operations succeeded, wrap the string in Ok and return it.
    Ok(s)
}
// Dummy main for context
fn main() {
    match read_username_from_file() {
        Ok(name) => println!("Username: {}", name),
        Err(e) => eprintln!("Error: {}", e),
    }
}

This use of ? is equivalent to manually writing a match for each operation that checks for Err and returns early, or extracts the Ok value otherwise. The ? operator makes this common pattern significantly more readable and concise. It directly expresses the intent: “Try this operation; if it fails, propagate the error; otherwise, continue with the successful result.”

15.3.2 Chaining ?

The power of ? becomes even more apparent when operations are chained:

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
// The entire operation can be condensed further.
fn read_username_from_file_chained() -> Result<String, io::Error> {
    let mut s = String::new();
    File::open("username.txt")?.read_to_string(&mut s)?; // Chained '?'
    Ok(s)
}

// Even more concisely using standard library functions:
fn read_username_from_file_stdlib() -> Result<String, io::Error> {
    std::fs::read_to_string("username.txt") // This function uses '?' internally
}
}
}

15.3.3 Returning Result from main

The main function, which typically returns (), can also be declared to return Result<(), E> where E is any type implementing the std::error::Error trait. This allows using the ? operator directly within main for cleaner error handling in simple applications.

use std::fs::File;
use std::io::Read;
use std::error::Error; // Required trait for the error type returned by main

fn main() -> Result<(), Box<dyn Error>> { // Return Box<dyn Error> for simplicity
    let mut file = File::open("config.ini")?; // If open fails, main returns Err
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // If read fails, main returns Err
    println!("Config content:\n{}", contents);
    Ok(()) // Indicate successful execution
}

If main returns Ok(()), the program exits with a status code 0. If main returns an Err(e), Rust prints the error description (using its Display implementation) to standard error and exits with a non-zero status code. Using Box<dyn Error> is a convenient way to allow different error types to be propagated out of main (discussed next).


15.4 Handling Multiple Error Types

Functions often call multiple operations that can fail with different error types (e.g., io::Error from file operations, ParseIntError from string parsing). However, a function returning Result<T, E> can only specify a single error type E. How can we handle this?

15.4.1 Defining a Custom Error Enum

The most idiomatic and type-safe approach is to define a custom error enum that aggregates all possible error types the function might produce.

Steps:

  1. Define an enum with variants for each potential error source, including custom application-specific errors.
  2. Implement std::fmt::Debug (usually via #[derive(Debug)]) for debugging output.
  3. Implement std::fmt::Display to provide user-friendly error messages.
  4. Implement std::error::Error to integrate with Rust’s error handling ecosystem (e.g., for source chaining).
  5. Implement From<OriginalError> for each underlying error type. This allows the ? operator to automatically convert the original error into your custom error type.
use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;

// 1. Define custom error enum
#[derive(Debug)] // 2. Implement Debug
enum ConfigError {
    Io(io::Error),       // Wrapper for I/O errors
    Parse(ParseIntError),    // Wrapper for parsing errors
    MissingValue(String),    // Custom application error
}

// 3. Implement Display for user messages
impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "Configuration IO error: {}", e),
            ConfigError::Parse(e) => write!(f, "Configuration parse error: {}", e),
            ConfigError::MissingValue(key) =>
                write!(f, "Missing configuration value for '{}'", key),
        }
    }
}

// 4. Implement Error trait
impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            // No 'ref' needed here due to match ergonomics on '&self'
            ConfigError::Io(e) => Some(e),   // 'e' is automatically '&io::Error'
            ConfigError::Parse(e) => Some(e), // 'e' is automatically '&ParseIntError'
            ConfigError::MissingValue(_) => None,
        }
    }
}

// 5. Implement From<T> for automatic conversion with '?'
impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> ConfigError {
        ConfigError::Io(err)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(err: ParseIntError) -> ConfigError {
        ConfigError::Parse(err)
    }
}

// Type alias for convenience
type Result<T> = std::result::Result<T, ConfigError>;

// Example function using the custom error and '?'
fn get_config_port(path: &str) -> Result<u16> {
    let content = fs::read_to_string(path)?; // '?' calls ConfigError::from(io::Error)

    let port_str = content
        .lines()
        .find(|line| line.starts_with("port="))
        .map(|line| line.trim_start_matches("port=").trim())
        .ok_or_else(|| ConfigError::MissingValue("port".to_string()))?; //Custom error

    let port = port_str.parse::<u16>()?; // '?' calls ConfigError::from(ParseIntError)
    Ok(port)
}

fn main() {
  // Setup dummy files
  fs::write("config_good.txt", "host=localhost\nport= 8080\n").unwrap();
  fs::write("config_bad_port.txt", "port=xyz").unwrap();
  fs::write("config_no_port.txt", "host=example.com").unwrap();

    println!("Good config: {:?}", get_config_port("config_good.txt"));
    println!("Bad port config: {:?}", get_config_port("config_bad_port.txt"));
    println!("No port config: {:?}", get_config_port("config_no_port.txt"));
    println!("Missing file: {:?}", get_config_port("config_missing.txt"));

  // Cleanup
  fs::remove_file("config_good.txt").ok();
  fs::remove_file("config_bad_port.txt").ok();
  fs::remove_file("config_no_port.txt").ok();
}

This approach provides the best type safety and clarity, allowing callers to match on specific error variants. The boilerplate for implementing traits can be reduced using libraries like thiserror.

15.4.2 Boxing Errors with Box<dyn Error>

For simpler applications or when detailed error matching by the caller is less critical, you can use a trait object to represent any error type that implements std::error::Error. This is typically done using Box<dyn std::error::Error + Send + Sync + 'static>. The Send and Sync bounds are often needed for thread safety, and 'static ensures the error type doesn’t contain non-static references.

A type alias simplifies this: type GenericResult<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;

use std::error::Error;
use std::fs;
use std::num::ParseIntError;

// Type alias for a Result returning a boxed error trait object
type GenericResult<T> = std::result::Result<T, Box<dyn Error + Send+Sync + 'static>>;

fn get_config_port_boxed(path: &str) -> GenericResult<u16> {
    let content = fs::read_to_string(path)?; // io::Error automatically boxed by '?'

    let port_str = content
        .lines()
        .find(|line| line.starts_with("port="))
        .map(|line| line.trim_start_matches("port=").trim())
        // Need to create an Error type if 'port=' is missing
        .ok_or_else(|| Box::<dyn Error + Send + Sync +
         'static>::from("Missing 'port=' line in config"))?;

    // ParseIntError automatically boxed by '?'
    let port = port_str.parse::<u16>()?;
    Ok(port)
}

fn main() {
  // Setup dummy files
  fs::write("config_good_boxed.txt", "host=localhost\nport= 8080\n").unwrap();
  fs::write("config_bad_port_boxed.txt", "port=xyz").unwrap();
  fs::write("config_no_port_boxed.txt", "host=example.com").unwrap();

println!("Good config: {:?}", get_config_port_boxed("config_good_boxed.txt"));
println!("Bad port config: {:?}", get_config_port_boxed("config_bad_port_boxed.txt"));
println!("No port config: {:?}", get_config_port_boxed("config_no_port_boxed.txt"));
println!("Missing file: {:?}", get_config_port_boxed("config_missing.txt"));

  // Cleanup
  fs::remove_file("config_good_boxed.txt").ok();
  fs::remove_file("config_bad_port_boxed.txt").ok();
  fs::remove_file("config_no_port_boxed.txt").ok();
}

Advantages:

  • Less boilerplate than custom enums.
  • Flexible; can hold any error type implementing the Error trait.
  • The ? operator works seamlessly because the standard library provides a generic impl<E: Error + Send + Sync + 'static> From<E> for Box<dyn Error + Send + Sync + 'static>.

Disadvantages:

  • Type Information Loss: The caller only knows an error occurred, not its specific type, making pattern matching on the error type impossible without runtime type checking (downcasting), which is less idiomatic.
  • Runtime Cost: Incurs heap allocation (Box) and dynamic dispatch overhead.

This approach is common in application-level code or examples where simplicity is prioritized over granular error handling by callers. Libraries like anyhow build upon this pattern, adding features like context and backtraces.

15.4.3 Using Error Handling Libraries

The Rust ecosystem offers crates that significantly reduce the boilerplate associated with error handling:

  • thiserror: Ideal for libraries. Uses procedural macros (#[derive(Error)]) to automatically generate Display, Error, and From implementations for your custom error enums.
  • anyhow: Best suited for applications. Provides an anyhow::Error type (similar to Box<dyn Error> but with context/backtrace) and anyhow::Result<T> type alias. Simplifies returning errors from various sources without defining custom enums.

Exploring these crates is recommended once you are comfortable with the fundamental concepts of Result and ?.


15.5 Unrecoverable Errors and panic!

While Result is the standard for handling expected failures, Rust uses panic! for situations deemed unrecoverable, typically indicating a bug.

15.5.1 The panic! Macro

Invoking panic!("Error message") causes the current thread to stop execution abruptly. By default, Rust performs stack unwinding:

  1. It walks back up the call stack.
  2. For each stack frame, it runs the destructors (drop implementations) of all live objects created within that frame, cleaning up resources like memory and file handles.
  3. After unwinding completes, the thread terminates. If it’s the main thread, the program exits with a non-zero status code, usually printing the panic message and potentially a backtrace.
fn main() {
    // This code will panic and, by default, unwind the stack before terminating.
    panic!("A critical invariant was violated!");
}

Some language constructs can also trigger implicit panics, turning potential undefined behavior (common in C/C++) into deterministic crashes:

  • Array Index Out of Bounds: Accessing my_array[invalid_index].
  • Integer Overflow: In debug builds, arithmetic operations like +, -, * panic on overflow. (In release builds, they typically wrap, similar to C).
  • Assertion Failures: Using macros like assert!, assert_eq!, assert_ne!.

Consider array bounds checking. In C, accessing an array out of bounds leads to undefined behavior. Rust prevents this with bounds checks:

fn main() {
    let data = [10, 20, 30];

    // Attempting to access an out-of-bounds index:
    let element = data[5]; // Index 5 is out of bounds for length 3

    println!("Element: {}", element); // This line will not be reached
}

Important Note on Compile-Time vs. Runtime Checks: In the specific example above using the constant index 5, the Rust compiler is often able to detect the out-of-bounds access at compile time due to optimizations and built-in lints (like unconditional_panic), issuing a compile-time error.

However, the crucial point is that Rust performs these bounds checks at runtime whenever the index cannot be proven safe or unsafe at compile time (e.g., if the index comes from user input, function arguments, or complex calculations). If such a runtime bounds check fails, the program will panic, preventing the memory safety violations common in C/C++. The example data[5] serves to illustrate this fundamental safety guarantee (bounds check leading to termination instead of UB), even though this specific literal case might be caught earlier by the compiler.

15.5.2 Assertion Macros

Assertions declare conditions that must be true at a certain point in the program. If the condition is false, the assertion macro calls panic!. They are primarily used to enforce internal invariants and in tests.

  • assert!(condition): Panics if condition is false.
  • assert_eq!(left, right): Panics if left != right, showing the differing values.
  • assert_ne!(left, right): Panics if left == right, showing the equal values.
fn check_positive(n: i32) {
    assert!(n > 0, "Input number must be positive, got {}", n);
    println!("Number {} is positive.", n);
}

fn main() {
    check_positive(10);
    check_positive(-5); // This call will panic
}

15.5.3 When to Panic vs. Return Result

The choice between panic! and Result is fundamental to Rust error handling:

Use panic! when:

  • A bug is detected (e.g., violated invariant, impossible state reached). The program is in a state you didn’t anticipate and cannot safely handle.
  • An operation is fundamentally unsafe to continue (e.g., index out of bounds prevents memory safety).
  • In examples, tests, or prototypes where you need to signal failure immediately without complex error handling.

Use Result when:

  • The error represents an expected or potential failure condition (e.g., file not found, network unavailable, invalid input).
  • The caller might be able to recover or react meaningfully to the error (e.g., retry, prompt user, use default).
  • You are writing library code. Libraries should generally avoid panicking, allowing the calling application to decide the error handling strategy.

Overusing panic! makes code less resilient and harder for others to integrate. Reserve it for truly exceptional, unrecoverable situations that indicate a programming error.

15.5.4 Customizing Panic Behavior

  • Abort on Panic: Instead of unwinding (which has some code size overhead), you can configure Rust to immediately abort the entire process upon panic. This yields smaller binaries but skips destructor cleanup. Configure this in Cargo.toml:
    [profile.release]
    panic = "abort"
    
  • Backtraces: For debugging panics, environment variable RUST_BACKTRACE=1 (or full) enables printing a stack trace showing the function call sequence leading to the panic!.
    RUST_BACKTRACE=1 cargo run
    

15.5.5 Catching Panics (catch_unwind)

Rust provides std::panic::catch_unwind to execute a closure and catch any panic that occurs within it. If the closure completes successfully, catch_unwind returns Ok(value). If the closure panics, it returns Err(panic_payload), where the payload contains information about the panic.

use std::panic;

fn panicky_function(trigger_panic: bool) {
    println!("Function start.");
    if trigger_panic {
        panic!("Intentional panic triggered!");
    }
    println!("Function end (no panic).");
}

fn main() {
    println!("Catching potential panic...");
    let result = panic::catch_unwind(|| {
        panicky_function(true); // This call will panic
    });

    match result {
        Ok(_) => println!("Call completed normally."),
        Err(payload) => println!("Caught panic! Payload: {:?}", payload),
    }
    println!("Execution continues after catch_unwind.");

    println!("\nRunning without panic...");
     let result_ok = panic::catch_unwind(|| {
        panicky_function(false); // This call will succeed
    });
     match result_ok {
        Ok(_) => println!("Call completed normally."),
        Err(payload) => println!("Caught panic! Payload: {:?}", payload),//Not reached
    }
}

Use catch_unwind with extreme caution. It is not intended for general error handling (use Result for that). Legitimate uses include:

  • Testing Frameworks: Isolating tests so a panic in one test doesn’t crash the whole suite.
  • Foreign Function Interface (FFI): Preventing Rust panics from unwinding across language boundaries (e.g., into C code), which is undefined behavior.
  • Thread Management: Allowing a controlling thread to detect and potentially restart a worker thread that panicked.

Do not use catch_unwind to simulate exception handling for recoverable errors.


15.6 Best Practices for Error Handling

  1. Prefer Result for Recoverable Errors: Avoid panic! for expected failures. Use Result to give callers control over error handling.
  2. Propagate Errors Upwards: Use ? to propagate errors cleanly. Let the function ultimately responsible for handling the user interaction or application state decide how to manage the error (log, retry, default, report). Avoid handling errors too early if the caller needs more context.
  3. Provide Contextual Error Information: When creating or mapping errors, add context about what failed and why. Custom error types (using thiserror or manual impls) or anyhow::Context are excellent for this. Good error messages drastically improve debuggability.
  4. Use unwrap and expect Sparingly: Only use them when a panic is acceptable or when program logic guarantees the operation cannot fail. In most production code, prefer explicit handling via match, if let, combinators, or ?.
  5. Choose the Right Error Strategy:
    • For libraries: Use custom error enums (often with thiserror) to provide stable, specific error types for callers.
    • For applications: anyhow or Box<dyn Error> can simplify error handling when granular matching isn’t the primary concern.

15.7 Summary

Rust elevates error handling from a matter of convention (as often in C) to a core language feature integrated with the type system.

  • Clear Distinction: It separates recoverable errors (Result<T, E>) from unrecoverable bugs/invariant violations (panic!).
  • Compile-Time Safety: Result<T, E> forces callers to acknowledge and handle potential failures, preventing accidentally ignored errors common in C.
  • Result<T, E>: The standard mechanism for functions that can fail recoverably. Handled via match, basic checks (is_ok/is_err), combinators, or propagated via ?.
  • panic!: Reserved for unrecoverable errors. Causes stack unwinding (or abort) and thread termination. Avoid in library code for expected failures.
  • ? Operator: Enables concise and readable propagation of Err values up the call stack within functions returning Result. Replaces manual match blocks for error checking and early return.
  • Multiple Error Types: Managed using custom error enums (best for libraries), Box<dyn Error> (simpler, for applications), or helper crates like thiserror and anyhow.
  • Best Practices: Emphasize returning Result, providing context, propagating errors, and using panic! (and unwrap/expect) judiciously.

By making error states explicit and requiring they be handled, Rust helps developers write more robust, reliable, and maintainable software compared to traditional approaches relying solely on programmer discipline.