Understanding Result and the ? Operator

The Result<T, E> type in Rust is an enum that represents either a successful value (Ok(T)) or an error (Err(E)). When writing functions that may fail, returning a Result allows the caller to explicitly handle both success and failure cases.

The ? operator provides a concise way to propagate errors from a function. If a function returns a Result, using ? on a Result value will return the Err variant early if one exists, otherwise it will unwrap and return the Ok variant.

Example: Simple File Reading

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

fn read_file_contents(path: &str) -> io::Result<String> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

In this example, File::open and file.read_to_string both return a Result. The ? operator is used to propagate any errors that may occur during these operations.

Custom Error Types

Using a custom error type can improve clarity and flexibility, especially in larger projects. Rust provides the thiserror crate to simplify the definition of error types.

Example: Custom Error with thiserror

Add thiserror to your Cargo.toml:

[dependencies]
thiserror = "1.0"

Then define and use a custom error type:

use thiserror::Error;
use std::io;

#[derive(Error, Debug)]
enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] io::Error),
    #[error("Invalid input: {0}")]
    InvalidInput(String),
}

fn process_input(input: &str) -> Result<(), AppError> {
    if input.is_empty() {
        return Err(AppError::InvalidInput("Input is empty".to_string()));
    }
    // Simulate some processing
    Ok(())
}

This example demonstrates how to create a custom error type and convert standard library errors into it using the #[from] attribute.

Combining Results and Errors

When working with multiple operations that can fail, you can combine Result values using the ? operator or using map and and_then.

Example: Combining Multiple Operations

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

fn read_and_process(path: &str) -> io::Result<String> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    if contents.is_empty() {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "Empty file content"));
    }
    Ok(contents)
}

In this example, if any of the operations fail or the file is empty, an appropriate error is returned.

Comparing Error Handling Approaches

ApproachDescriptionProsCons
? operatorPropagates errors early in a functionClean, conciseOnly works in Result/Option functions
match expressionExplicitly matches Ok and Err variantsFull control over each caseVerbose
unwrap() / expect()Unwraps Result, panics on errorQuick for simple casesNot safe for production code
Custom error typesCombines multiple error sources into a single typeClear, flexible, composableRequires additional setup

Best Practices for Error Handling

  • Prefer ? over match when the logic is simple and you want to avoid boilerplate.
  • Use custom error types in larger projects to make error handling consistent and meaningful.
  • Avoid unwrap() in production code; it can lead to panics and crashes.
  • Document error conditions in function documentation to inform users about possible failure scenarios.

Real-World Example: JSON Parsing with Error Handling

use std::fs::File;
use std::io::Read;
use serde_json::Value;
use thiserror::Error;

#[derive(Error, Debug)]
enum JsonError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),
}

fn read_json_file(path: &str) -> Result<Value, JsonError> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let value: Value = serde_json::from_str(&contents)?;
    Ok(value)
}

This example shows how to handle both I/O and JSON parsing errors using a custom error type and the ? operator.


Learn more with useful resources