Rust Propagating Errors
Introduction
When writing Rust code, you'll inevitably encounter situations where functions can fail. Instead of handling every error at the point it occurs, Rust provides elegant mechanisms to propagate errors up the call stack. This allows the calling function to decide how to handle the error.
Error propagation is a core part of Rust's error handling philosophy. Rather than using exceptions like many other languages, Rust uses the Result
type and the ?
operator to explicitly pass errors up the call stack. This approach makes error paths clear and ensures errors aren't accidentally ignored.
In this guide, we'll explore how to effectively propagate errors in Rust, starting with the basics and moving to more advanced patterns.
Understanding the Basics
The Result Type Recap
Before diving into error propagation, let's briefly review Rust's Result
type:
enum Result<T, E> {
Ok(T), // Success case containing a value of type T
Err(E), // Error case containing an error of type E
}
Functions that might fail typically return a Result
:
fn read_username_from_file() -> Result<String, std::io::Error> {
// Implementation that might succeed or fail
}
Manual Error Propagation
The most basic way to propagate errors is to use pattern matching with match
:
fn read_username_from_file() -> Result<String, std::io::Error> {
let file_result = std::fs::File::open("username.txt");
let mut file = match file_result {
Ok(file) => file,
Err(error) => return Err(error), // Propagate the error
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(error) => Err(error), // Propagate the error
}
}
Input:
- A file named "username.txt" that may or may not exist
Output:
- If successful:
Ok(String)
containing the file's contents - If file doesn't exist:
Err(std::io::Error)
with a "No such file or directory" error - If file can't be read:
Err(std::io::Error)
with a different error message
While this works, it's verbose. Let's look at a better way.
The ? Operator
Rust provides the ?
operator as a shorthand for the pattern above. When placed after a Result
, it:
- Returns the value inside
Ok
if successful - Returns early from the function with the error if it's an
Err
Here's the same function using the ?
operator:
fn read_username_from_file() -> Result<String, std::io::Error> {
let mut file = std::fs::File::open("username.txt")?;
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username)
}
The ?
operator makes the code much cleaner while maintaining the same error propagation behavior.
How the ? Operator Works
Let's visualize how the ?
operator works:
When you use ?
on a Result
, it implicitly performs type conversion through the From
trait if the error types don't match exactly.
Chaining Methods with ?
We can make our code even more concise by chaining method calls with the ?
operator:
fn read_username_from_file() -> Result<String, std::io::Error> {
let mut username = String::new();
std::fs::File::open("username.txt")?.read_to_string(&mut username)?;
Ok(username)
}
This approach reads as: "Open the file, and if that succeeds, read its contents into the string."
Even Shorter: File Utilities
For common operations, Rust's standard library provides convenient functions:
fn read_username_from_file() -> Result<String, std::io::Error> {
std::fs::read_to_string("username.txt")
}
This one-liner has the same behavior as our previous examples!
Using ? in Different Return Types
Using ? with Option
The ?
operator also works with Option
types:
fn first_line(text: &str) -> Option<&str> {
text.lines().next()
}
fn main() -> Option<()> {
let text = "Hello
World";
let first = first_line(text)?;
println!("First line: {}", first);
Some(())
}
Mixing Result and Option
You can't directly use the ?
operator to mix Result
and Option
. However, you can convert between them using methods like ok_or
and ok_or_else
:
fn process_file() -> Result<(), std::io::Error> {
// Convert Option to Result with ok_or
let content = std::fs::read_to_string("config.txt")?
.lines()
.next()
.ok_or(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"File is empty"
))?;
println!("First line: {}", content);
Ok(())
}
Real-World Error Propagation Examples
Web Server Example
Here's a function from a web server that reads a configuration file and validates it:
use std::fs;
use std::io;
use std::path::Path;
// A custom error enum
#[derive(Debug)]
enum ConfigError {
IoError(io::Error),
InvalidFormat(String),
MissingField(String),
}
// Implement From trait for automatic conversion
impl From<io::Error> for ConfigError {
fn from(err: io::Error) -> Self {
ConfigError::IoError(err)
}
}
struct ServerConfig {
port: u16,
host: String,
}
fn load_config(path: &Path) -> Result<ServerConfig, ConfigError> {
// Read file contents (? will convert io::Error to ConfigError)
let content = fs::read_to_string(path)?;
// Parse port
let port_line = content.lines().find(|line| line.starts_with("PORT="))
.ok_or(ConfigError::MissingField("PORT".to_string()))?;
let port: u16 = port_line[5..].parse()
.map_err(|_| ConfigError::InvalidFormat("PORT must be a number".to_string()))?;
// Parse host
let host_line = content.lines().find(|line| line.starts_with("HOST="))
.ok_or(ConfigError::MissingField("HOST".to_string()))?;
let host = host_line[5..].to_string();
if host.is_empty() {
return Err(ConfigError::InvalidFormat("HOST cannot be empty".to_string()));
}
Ok(ServerConfig { port, host })
}
Input: A configuration file with content like:
PORT=8080
HOST=localhost
Output:
- If successful:
Ok(ServerConfig)
with the parsed values - If file doesn't exist:
Err(ConfigError::IoError(...))
- If PORT is missing:
Err(ConfigError::MissingField("PORT"))
- If HOST is missing:
Err(ConfigError::MissingField("HOST"))
- If PORT isn't a valid number:
Err(ConfigError::InvalidFormat(...))
Database Connection Example
Here's an example of a function that connects to a database and executes a query:
use std::error::Error;
use std::fmt;
// Custom error types
#[derive(Debug)]
enum DatabaseError {
ConnectionFailed(String),
QueryFailed(String),
NoResults,
}
impl fmt::Display for DatabaseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
DatabaseError::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg),
DatabaseError::QueryFailed(msg) => write!(f, "Query failed: {}", msg),
DatabaseError::NoResults => write!(f, "Query returned no results"),
}
}
}
impl Error for DatabaseError {}
// Mock database functions
fn connect_to_db(url: &str) -> Result<DatabaseConnection, DatabaseError> {
// In a real application, this would actually connect to a database
if url.starts_with("postgres://") {
Ok(DatabaseConnection {})
} else {
Err(DatabaseError::ConnectionFailed(
"Invalid connection string".to_string(),
))
}
}
struct DatabaseConnection {}
impl DatabaseConnection {
fn execute_query(&self, query: &str) -> Result<Vec<String>, DatabaseError> {
// In a real application, this would execute a database query
if query.to_lowercase().starts_with("select") {
Ok(vec!["result1".to_string(), "result2".to_string()])
} else {
Err(DatabaseError::QueryFailed(
"Only SELECT queries are supported".to_string(),
))
}
}
}
// Function that uses ? to propagate errors
fn get_user_names(db_url: &str) -> Result<Vec<String>, DatabaseError> {
let conn = connect_to_db(db_url)?;
let results = conn.execute_query("SELECT name FROM users")?;
if results.is_empty() {
return Err(DatabaseError::NoResults);
}
Ok(results)
}
Using this function:
fn main() {
match get_user_names("postgres://localhost/mydb") {
Ok(names) => {
println!("Found users:");
for name in names {
println!("- {}", name);
}
}
Err(e) => eprintln!("Error: {}", e),
}
}
Advanced Error Propagation Patterns
The anyhow
Crate for Quick Error Handling
For applications that want simple error handling, the anyhow
crate provides an easy way to propagate errors:
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
fn read_config(path: &Path) -> Result<String> {
fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))
}
fn parse_config(content: &str) -> Result<u32> {
let value = content.trim().parse::<u32>()
.context("Failed to parse config value as a number")?;
Ok(value)
}
fn load_threshold(path: &Path) -> Result<u32> {
let content = read_config(path)?;
let threshold = parse_config(&content)?;
Ok(threshold)
}
The thiserror
Crate for Custom Error Types
For libraries that need custom error types, the thiserror
crate makes it easy to define them:
use std::fs;
use std::io;
use std::num::ParseIntError;
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Parse error: {0}")]
Parse(#[from] ParseIntError),
#[error("Invalid configuration: {0}")]
Invalid(String),
}
fn load_config(path: &Path) -> Result<u32, ConfigError> {
let content = fs::read_to_string(path)?;
let value = content.trim().parse::<u32>()?;
if value == 0 {
return Err(ConfigError::Invalid("Value cannot be zero".to_string()));
}
Ok(value)
}
Best Practices for Error Propagation
-
Be Specific at Boundaries: When defining public functions, use specific error types that tell users exactly what went wrong.
-
Provide Context: Add context to errors to make debugging easier. The
anyhow
crate'swith_context
method is excellent for this. -
Design for Composition: Define error types that work well with Rust's
?
operator. -
Choose the Right Level of Detail: Libraries should use specific error types, while applications can often use catch-all types like
anyhow::Error
. -
Document Error Conditions: In documentation, clearly state what errors can be returned and under what conditions.
/// Reads a user configuration file.
///
/// # Errors
///
/// Returns an error if:
/// - The file does not exist or cannot be read
/// - The file contains invalid UTF-8 data
/// - The configuration format is invalid
fn read_user_config() -> Result<Config, ConfigError> {
// Implementation
}
Summary
Rust's error propagation system provides a clean, explicit way to handle errors:
- The
Result
type makes error paths explicit - The
?
operator simplifies error propagation - Type conversions via the
From
trait allow errors to be transformed automatically - Chaining method calls with
?
allows for concise, readable code - Crates like
anyhow
andthiserror
build on Rust's foundations to provide even better error handling
Error propagation in Rust strikes a balance between safety and ergonomics. By making errors explicit and using the ?
operator, your code can handle failure cases gracefully without becoming overly verbose.
Additional Resources
- Rust Book: Error Handling Chapter
- Rust By Example: Error Handling
- The
anyhow
Crate Documentation - The
thiserror
Crate Documentation
Exercises
-
Modify the
read_username_from_file
function to handle multiple possible username files (try "username.txt" first, then "backup_username.txt" if the first fails). -
Create a function that reads and parses a configuration file with multiple settings, propagating appropriate errors for missing fields or invalid values.
-
Implement a custom error type that can represent at least three different error conditions, and write a function that returns this error type.
-
Extend the database example to include a function that executes multiple queries in sequence, propagating errors appropriately.
-
Explore the
anyhow
crate further by rewriting one of the examples to use its error handling features.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)