Error Handling Strategies

Need to handle errors properly in Rust? This guide provides practical strategies for working with Result and Option, creating custom errors, propagating errors elegantly, and choosing the right error handling approach.

Problem: Function Can Fail

Scenario

Your function performs I/O or validation that can fail.

use std::fs::File;

fn open_config() -> File {
    File::open("config.toml")  // Error: returns Result, not File
}

Solution: Return Result<T, E>

Use Result to represent success or failure.

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

fn open_config() -> Result<File, io::Error> {
    File::open("config.toml")
}

fn main() {
    match open_config() {
        Ok(file) => println!("Opened file: {:?}", file),
        Err(e) => eprintln!("Failed to open file: {}", e),
    }
}

How it works: Result<T, E> is either Ok(T) for success or Err(E) for failure. Caller must handle both cases.


Problem: Propagating Errors is Verbose

Scenario

You need to propagate errors through multiple function calls.

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

fn read_config() -> Result<String, io::Error> {
    let file = File::open("config.toml");
    let mut file = match file {
        Ok(f) => f,
        Err(e) => return Err(e),
    };

    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(e) => Err(e),
    }
}

Solution: Use the ? Operator

Propagate errors concisely with ?.

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

fn read_config() -> Result<String, io::Error> {
    let mut file = File::open("config.toml")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

How it works: ? returns early with Err if result is error, otherwise unwraps Ok value.

Even more concise:

use std::fs;
use std::io;

fn read_config() -> Result<String, io::Error> {
    fs::read_to_string("config.toml")
}

Problem: Multiple Error Types

Scenario

Function calls return different error types.

use std::fs;
use std::num::ParseIntError;

fn read_number() -> Result<i32, ???> {
    let contents = fs::read_to_string("number.txt")?;  // io::Error
    let number = contents.trim().parse()?;             // ParseIntError
    Ok(number)
}

Solution 1: Box for Applications

Use trait objects for simple error handling.

use std::error::Error;
use std::fs;

fn read_number() -> Result<i32, Box<dyn Error>> {
    let contents = fs::read_to_string("number.txt")?;
    let number = contents.trim().parse()?;
    Ok(number)
}

fn main() {
    match read_number() {
        Ok(n) => println!("Number: {}", n),
        Err(e) => eprintln!("Error: {}", e),
    }
}

How it works: Any type implementing Error can be converted to Box<dyn Error>.

Use case: Applications where you just need to report errors.

Solution 2: anyhow for Applications

Use anyhow crate for ergonomic error handling.

use anyhow::Result;
use std::fs;

fn read_number() -> Result<i32> {
    let contents = fs::read_to_string("number.txt")?;
    let number = contents.trim().parse()?;
    Ok(number)
}

fn main() -> Result<()> {
    let number = read_number()?;
    println!("Number: {}", number);
    Ok(())
}

Benefits:

  • Result<T> defaults to Result<T, anyhow::Error>
  • Automatic conversion from any error type
  • Context with .context()

Solution 3: Custom Error Enum for Libraries

Define your own error type with thiserror.

use thiserror::Error;
use std::fs;
use std::num::ParseIntError;
use std::io;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Failed to read file")]
    Io(#[from] io::Error),

    #[error("Failed to parse number")]
    Parse(#[from] ParseIntError),
}

fn read_number() -> Result<i32, ConfigError> {
    let contents = fs::read_to_string("number.txt")?;
    let number = contents.trim().parse()?;
    Ok(number)
}

How it works: #[from] automatically converts errors. #[error] provides display message.

Use case: Libraries that need specific error types for callers to match on.


Problem: Adding Context to Errors

Scenario

Error messages lack context about what operation failed.

use std::fs;

fn load_config() -> Result<String, std::io::Error> {
    fs::read_to_string("config.toml")  // Error: "No such file or directory"
}

Solution: Add Context with anyhow

Provide additional information about the failure.

use anyhow::{Context, Result};
use std::fs;

fn load_config() -> Result<String> {
    fs::read_to_string("config.toml")
        .context("Failed to load config.toml")?;
    Ok(())
}

fn main() -> Result<()> {
    load_config()?;
    Ok(())
}

Output:

Error: Failed to load config.toml

Caused by:
    No such file or directory (os error 2)

Multiple contexts:

fn process() -> Result<()> {
    load_config()
        .context("Configuration initialization failed")?;
    Ok(())
}

Problem: Optional Values

Scenario

Value might not exist (e.g., searching, parsing).

fn find_user(id: u32) -> ??? {
    if id == 1 {
        User { name: "Alice" }
    } else {
        // What to return?
    }
}

Solution: Use Option

Represent presence or absence of a value.

struct User {
    name: String,
}

fn find_user(id: u32) -> Option<User> {
    if id == 1 {
        Some(User { name: String::from("Alice") })
    } else {
        None
    }
}

fn main() {
    match find_user(1) {
        Some(user) => println!("Found: {}", user.name),
        None => println!("User not found"),
    }
}

Converting Option to Result

When you need to provide an error for None.

use anyhow::{Result, anyhow};

fn get_user(id: u32) -> Result<User> {
    find_user(id)
        .ok_or_else(|| anyhow!("User {} not found", id))
}

Problem: Want to Panic on Error During Development

Scenario

During development, you want to crash on errors to find bugs quickly.

Solution 1: unwrap() for Prototyping

Panic if error occurs.

use std::fs;

fn main() {
    let contents = fs::read_to_string("config.toml").unwrap();
    println!("{}", contents);
}

Warning: Never use in production code. Program panics if file doesn’t exist.

Solution 2: expect() with Message

Panic with custom message.

use std::fs;

fn main() {
    let contents = fs::read_to_string("config.toml")
        .expect("config.toml must exist");
    println!("{}", contents);
}

Better error message: Shows your message when panic occurs.

Solution 3: Proper Error Handling for Production

Replace unwrap/expect with proper handling.

use std::fs;
use anyhow::Result;

fn main() -> Result<()> {
    let contents = fs::read_to_string("config.toml")?;
    println!("{}", contents);
    Ok(())
}

Problem: Handling Multiple Operations That Can Fail

Scenario

You need to perform several operations, any of which can fail.

Solution: Chain with ?

use anyhow::{Result, Context};
use std::fs;

fn initialize() -> Result<()> {
    let config = fs::read_to_string("config.toml")
        .context("Failed to read config")?;

    let data = fs::read_to_string("data.json")
        .context("Failed to read data")?;

    let processed = process_data(&data)
        .context("Failed to process data")?;

    save_results(&processed)
        .context("Failed to save results")?;

    Ok(())
}

fn process_data(data: &str) -> Result<String> {
    // Processing logic
    Ok(data.to_uppercase())
}

fn save_results(data: &str) -> Result<()> {
    fs::write("output.txt", data)?;
    Ok(())
}

Problem: Recovering from Specific Errors

Scenario

Some errors are recoverable, others are fatal.

Solution: Match on Error Kinds

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

fn open_or_create() -> File {
    match File::open("data.txt") {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => {
                File::create("data.txt")
                    .expect("Failed to create file")
            }
            other_error => {
                panic!("Failed to open file: {:?}", other_error);
            }
        },
    }
}

With anyhow:

use anyhow::{Result, Context};
use std::fs::File;
use std::io::ErrorKind;

fn open_or_create() -> Result<File> {
    match File::open("data.txt") {
        Ok(file) => Ok(file),
        Err(error) => match error.kind() {
            ErrorKind::NotFound => {
                File::create("data.txt")
                    .context("Failed to create data.txt")
            }
            _ => Err(error).context("Failed to open data.txt"),
        },
    }
}

Problem: Collecting Results from Iterator

Scenario

You have an iterator of Result values and want to collect them.

let numbers = vec!["1", "2", "3", "not a number", "5"];
let parsed: Vec<i32> = numbers
    .iter()
    .map(|s| s.parse())  // Returns Result<i32, ParseIntError>
    // How to collect and handle errors?

Solution: collect() into Result<Vec>

Collect all successes or first error.

fn parse_all(inputs: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    inputs.iter()
        .map(|s| s.parse())
        .collect()
}

fn main() {
    let numbers = vec!["1", "2", "3"];
    match parse_all(&numbers) {
        Ok(parsed) => println!("Parsed: {:?}", parsed),
        Err(e) => eprintln!("Parse error: {}", e),
    }
}

How it works: collect() can collect Iterator<Item=Result<T,E>> into Result<Vec<T>, E>. Returns first error encountered.

Filter Out Errors

Keep only successful values.

fn main() {
    let numbers = vec!["1", "2", "bad", "3"];
    let parsed: Vec<i32> = numbers
        .iter()
        .filter_map(|s| s.parse().ok())
        .collect();
    println!("{:?}", parsed);  // [1, 2, 3]
}

Problem: Custom Error Type for Your Library

Scenario

You’re writing a library and need a proper error type.

Solution: Define Error with thiserror

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("Connection failed: {0}")]
    ConnectionFailed(String),

    #[error("Query failed: {query}")]
    QueryFailed { query: String },

    #[error("Record not found with id: {0}")]
    NotFound(u32),

    #[error("Database is read-only")]
    ReadOnly,

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

pub fn connect(url: &str) -> Result<Connection, DatabaseError> {
    if url.is_empty() {
        return Err(DatabaseError::ConnectionFailed(
            "Empty URL".to_string()
        ));
    }
    // Connection logic
    Ok(Connection {})
}

pub struct Connection {}

impl Connection {
    pub fn query(&self, sql: &str) -> Result<Vec<Row>, DatabaseError> {
        if sql.is_empty() {
            return Err(DatabaseError::QueryFailed {
                query: sql.to_string(),
            });
        }
        Ok(vec![])
    }
}

pub struct Row {}

Benefits:

  • Structured error types callers can match on
  • Automatic Display implementation
  • Automatic Error trait implementation
  • Source error chaining with #[from]

Problem: Deciding Between panic! and Result

Scenario

Should you panic or return an error?

Guidelines

Use panic! when:

  • Contract violation (programming error, not runtime error)
  • Unrecoverable situation
  • Prototyping
pub fn get_item(index: usize) -> &Item {
    if index >= self.items.len() {
        panic!("Index out of bounds: {}", index);
    }
    &self.items[index]
}

Use Result when:

  • Expected runtime failure (I/O, network, user input)
  • Caller might recover
  • Library code (let caller decide)
pub fn load_config(path: &str) -> Result<Config, ConfigError> {
    // File might not exist - expected, recoverable
    let contents = fs::read_to_string(path)?;
    parse_config(&contents)
}

Common Pitfalls

Pitfall 1: Overusing unwrap()

Problem: Panics in production.

// Bad
let file = File::open("config.toml").unwrap();

Solution: Handle errors properly.

// Good
let file = File::open("config.toml")?;

Pitfall 2: Swallowing Errors

Problem: Ignoring errors with let _ = ...

// Bad - error ignored
let _ = fs::write("log.txt", "data");

Solution: At minimum, log the error.

// Good
if let Err(e) = fs::write("log.txt", "data") {
    eprintln!("Failed to write log: {}", e);
}

Pitfall 3: Not Adding Context

Problem: Generic error messages.

// Bad - which file? which operation?
fs::read_to_string(path)?;

Solution: Add context.

// Good
fs::read_to_string(path)
    .with_context(|| format!("Failed to read config from {}", path))?;

Related Patterns

  • Result Combinators: map, and_then, or_else for transforming results
  • Option Combinators: map, and_then, filter for optional values
  • Error Traits: Implement std::error::Error for custom types
  • From Conversions: Automatic error type conversions

Related Resources


Handle errors gracefully to build robust Rust applications!

Last updated