| Contents |

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.
}

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):

    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
    } 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:

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:

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:

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)
}

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:

// 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() {

    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"));

}

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() {

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"));

}

Advantages:

Disadvantages:

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:

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:

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.

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:

Use Result when:

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

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:

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:

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.

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.



| Contents |