Error Handling
Rust's error handling is built around explicit error types rather than exceptions. This approach makes errors visible in function signatures and forces developers to handle them explicitly, leading to more robust and maintainable code.
Error Handling Philosophy
Rust categorizes failures into two main types:
- Recoverable errors: Represented by
Result<T, E>
- Unrecoverable errors: Handled by panicking with
panic!
// Recoverable error - function returns Result
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Cannot divide by zero".to_string())
} else {
Ok(a / b)
}
}
// Unrecoverable error - panics
fn access_array(arr: &[i32], index: usize) -> i32 {
if index >= arr.len() {
panic!("Index {} out of bounds for array of length {}", index, arr.len());
}
arr[index]
}
fn main() {
// Handle recoverable error
match divide(10.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(error) => println!("Error: {}", error),
}
// This would panic
let numbers = [1, 2, 3];
// access_array(&numbers, 5); // Uncomment to see panic
}
The Result Type
Result<T, E>
is an enum representing either success (Ok(T)
) or failure (Err(E)
):
enum Result<T, E> {
Ok(T),
Err(E),
}
Basic Result Usage
use std::fs::File;
use std::io::ErrorKind;
fn open_file(filename: &str) -> Result<File, std::io::Error> {
File::open(filename)
}
fn main() {
match open_file("hello.txt") {
Ok(file) => println!("File opened successfully: {:?}", file),
Err(error) => match error.kind() {
ErrorKind::NotFound => println!("File not found: {}", error),
ErrorKind::PermissionDenied => println!("Permission denied: {}", error),
other_error => println!("Problem opening file: {:?}", other_error),
},
}
}
Result Methods
fn demonstrate_result_methods() {
let success: Result<i32, &str> = Ok(42);
let failure: Result<i32, &str> = Err("something went wrong");
// is_ok() and is_err()
println!("Success is_ok: {}", success.is_ok());
println!("Failure is_err: {}", failure.is_err());
// unwrap_or() - provide default for error
println!("Success or default: {}", success.unwrap_or(0));
println!("Failure or default: {}", failure.unwrap_or(0));
// unwrap_or_else() - compute default lazily
let computed = failure.unwrap_or_else(|err| {
println!("Computing default due to error: {}", err);
-1
});
println!("Computed default: {}", computed);
// map() - transform success value
let doubled = success.map(|x| x * 2);
println!("Doubled: {:?}", doubled);
// map_err() - transform error value
let better_error = failure.map_err(|err| format!("Error: {}", err));
println!("Better error: {:?}", better_error);
}
Error Propagation with ?
The ?
operator provides a concise way to propagate errors:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("username.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
// Equivalent verbose version
fn read_username_from_file_verbose() -> Result<String, io::Error> {
let username_file = File::open("username.txt");
let mut username_file = match username_file {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
// Chain multiple operations
fn read_and_process_file() -> Result<usize, io::Error> {
let contents = std::fs::read_to_string("data.txt")?;
let processed = contents.trim().to_uppercase();
Ok(processed.len())
}
fn main() {
match read_username_from_file() {
Ok(username) => println!("Username: {}", username),
Err(error) => println!("Failed to read username: {}", error),
}
}
Custom Error Types
Create custom error types for domain-specific errors:
use std::fmt;
#[derive(Debug)]
enum MathError {
DivisionByZero,
NegativeSquareRoot,
InvalidInput(String),
}
impl fmt::Display for MathError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
MathError::DivisionByZero => write!(f, "Cannot divide by zero"),
MathError::NegativeSquareRoot => write!(f, "Cannot take square root of negative number"),
MathError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
}
}
}
impl std::error::Error for MathError {}
fn divide(a: f64, b: f64) -> Result<f64, MathError> {
if b == 0.0 {
Err(MathError::DivisionByZero)
} else {
Ok(a / b)
}
}
fn sqrt(x: f64) -> Result<f64, MathError> {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn calculate(input: &str) -> Result<f64, MathError> {
let num: f64 = input.parse()
.map_err(|_| MathError::InvalidInput(input.to_string()))?;
let divided = divide(num, 2.0)?;
let result = sqrt(divided)?;
Ok(result)
}
fn main() {
let test_cases = vec!["16", "0", "-4", "abc"];
for input in test_cases {
match calculate(input) {
Ok(result) => println!("calculate({}) = {}", input, result),
Err(e) => println!("calculate({}) failed: {}", input, e),
}
}
}
Error Trait and Error Chaining
The std::error::Error
trait provides a standard interface for errors:
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct ParseError {
message: String,
line: usize,
column: usize,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Parse error at line {}, column {}: {}",
self.line, self.column, self.message)
}
}
impl Error for ParseError {}
#[derive(Debug)]
struct ValidationError {
field: String,
value: String,
reason: String,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Validation failed for field '{}' with value '{}': {}",
self.field, self.value, self.reason)
}
}
impl Error for ValidationError {}
// Composite error type
#[derive(Debug)]
enum ProcessingError {
Parse(ParseError),
Validation(ValidationError),
Io(std::io::Error),
}
impl fmt::Display for ProcessingError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ProcessingError::Parse(e) => write!(f, "Processing failed due to parse error: {}", e),
ProcessingError::Validation(e) => write!(f, "Processing failed due to validation error: {}", e),
ProcessingError::Io(e) => write!(f, "Processing failed due to I/O error: {}", e),
}
}
}
impl Error for ProcessingError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ProcessingError::Parse(e) => Some(e),
ProcessingError::Validation(e) => Some(e),
ProcessingError::Io(e) => Some(e),
}
}
}
// Conversion implementations for automatic error conversion with ?
impl From<ParseError> for ProcessingError {
fn from(error: ParseError) -> Self {
ProcessingError::Parse(error)
}
}
impl From<ValidationError> for ProcessingError {
fn from(error: ValidationError) -> Self {
ProcessingError::Validation(error)
}
}
impl From<std::io::Error> for ProcessingError {
fn from(error: std::io::Error) -> Self {
ProcessingError::Io(error)
}
}
fn parse_config(content: &str) -> Result<String, ParseError> {
if content.is_empty() {
return Err(ParseError {
message: "Empty input".to_string(),
line: 1,
column: 1,
});
}
Ok(content.to_string())
}
fn validate_config(config: &str) -> Result<(), ValidationError> {
if config.len() < 10 {
return Err(ValidationError {
field: "config".to_string(),
value: config.to_string(),
reason: "Config too short".to_string(),
});
}
Ok(())
}
fn process_config_file(filename: &str) -> Result<String, ProcessingError> {
let content = std::fs::read_to_string(filename)?; // io::Error -> ProcessingError
let config = parse_config(&content)?; // ParseError -> ProcessingError
validate_config(&config)?; // ValidationError -> ProcessingError
Ok(config)
}
fn main() {
match process_config_file("config.txt") {
Ok(config) => println!("Processed config: {}", config),
Err(e) => {
println!("Error: {}", e);
// Print error chain
let mut source = e.source();
while let Some(err) = source {
println!("Caused by: {}", err);
source = err.source();
}
}
}
}
Error Handling Patterns
Early Return Pattern
fn validate_user_input(input: &str) -> Result<u32, String> {
if input.is_empty() {
return Err("Input cannot be empty".to_string());
}
if input.len() > 10 {
return Err("Input too long".to_string());
}
let number: u32 = input.parse()
.map_err(|_| "Invalid number format".to_string())?;
if number == 0 {
return Err("Number cannot be zero".to_string());
}
Ok(number)
}
Collecting Results
fn parse_numbers(strings: Vec<&str>) -> Result<Vec<i32>, std::num::ParseIntError> {
strings.into_iter()
.map(|s| s.parse::<i32>())
.collect() // Stops at first error
}
fn parse_numbers_collect_errors(strings: Vec<&str>) -> (Vec<i32>, Vec<std::num::ParseIntError>) {
strings.into_iter()
.map(|s| s.parse::<i32>())
.partition_map(|result| match result {
Ok(num) => itertools::Either::Left(num),
Err(err) => itertools::Either::Right(err),
})
}
// Alternative without itertools
fn parse_numbers_with_errors(strings: Vec<&str>) -> (Vec<i32>, Vec<String>) {
let mut numbers = Vec::new();
let mut errors = Vec::new();
for s in strings {
match s.parse::<i32>() {
Ok(num) => numbers.push(num),
Err(e) => errors.push(format!("Failed to parse '{}': {}", s, e)),
}
}
(numbers, errors)
}
fn main() {
let inputs = vec!["1", "2", "abc", "4", "def"];
match parse_numbers(inputs.clone()) {
Ok(numbers) => println!("All numbers: {:?}", numbers),
Err(e) => println!("Failed to parse all numbers: {}", e),
}
let (numbers, errors) = parse_numbers_with_errors(inputs);
println!("Successfully parsed: {:?}", numbers);
println!("Errors: {:?}", errors);
}
Error Recovery
fn read_config_with_fallback(primary_path: &str, fallback_path: &str) -> Result<String, std::io::Error> {
match std::fs::read_to_string(primary_path) {
Ok(content) => Ok(content),
Err(_) => {
println!("Primary config not found, trying fallback...");
std::fs::read_to_string(fallback_path)
}
}
}
fn parse_with_default(input: &str) -> i32 {
input.parse().unwrap_or_else(|_| {
println!("Failed to parse '{}', using default value", input);
0
})
}
anyhow and thiserror Crates
While not part of the standard library, these crates are commonly used for error handling:
Using anyhow for Application Errors
// In Cargo.toml: anyhow = "1.0"
use anyhow::{Context, Result, anyhow};
fn read_user_from_file(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("Failed to read user file from {}", path))?
.parse()
.context("Failed to parse user data")
}
fn process_user() -> Result<()> {
let user = read_user_from_file("user.json")?;
if user.is_empty() {
return Err(anyhow!("User data is empty"));
}
println!("Processing user: {}", user);
Ok(())
}
Using thiserror for Library Errors
// In Cargo.toml: thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataStoreError {
#[error("Data store disconnected")]
Disconnect(#[from] std::io::Error),
#[error("The data for key `{key}` is not available")]
Redaction { key: String },
#[error("Invalid header (expected {expected:?}, found {found:?})")]
InvalidHeader { expected: String, found: String },
#[error("Unknown data store error")]
Unknown,
}
Testing Error Conditions
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_divide_by_zero() {
match divide(10.0, 0.0) {
Err(MathError::DivisionByZero) => (), // Expected
Ok(_) => panic!("Expected division by zero error"),
Err(other) => panic!("Expected division by zero, got {:?}", other),
}
}
#[test]
fn test_successful_calculation() {
let result = calculate("16").unwrap();
assert!((result - 2.83).abs() < 0.01); // sqrt(16/2) ≈ 2.83
}
#[test]
fn test_error_chain() {
let error = ProcessingError::Parse(ParseError {
message: "test error".to_string(),
line: 1,
column: 1,
});
assert!(error.source().is_some());
}
// Test that ? operator works correctly
#[test]
fn test_error_propagation() {
fn might_fail() -> Result<i32, &'static str> {
Err("something went wrong")
}
fn calls_might_fail() -> Result<i32, &'static str> {
let value = might_fail()?;
Ok(value * 2)
}
assert!(calls_might_fail().is_err());
}
}
Best Practices
1. Use Result for Recoverable Errors
// Good: Recoverable error with Result
fn parse_age(input: &str) -> Result<u8, String> {
let age: u8 = input.parse()
.map_err(|_| format!("Invalid age format: '{}'", input))?;
if age > 150 {
return Err("Age cannot be greater than 150".to_string());
}
Ok(age)
}
// Avoid: Panicking for recoverable errors
fn parse_age_bad(input: &str) -> u8 {
input.parse().expect("Invalid age") // Don't panic for user input!
}
2. Provide Informative Error Messages
// Good: Descriptive error messages
#[derive(Debug)]
enum ConfigError {
MissingField { field: String, section: String },
InvalidValue { field: String, value: String, expected: String },
FileNotFound { path: String },
}
// Avoid: Generic error messages
#[derive(Debug)]
enum BadConfigError {
Error,
BadValue,
NotFound,
}
3. Use ? for Error Propagation
// Good: Clean error propagation
fn load_and_parse_config(path: &str) -> Result<Config, ConfigError> {
let contents = std::fs::read_to_string(path)
.map_err(|_| ConfigError::FileNotFound { path: path.to_string() })?;
let config = parse_config(&contents)?;
validate_config(&config)?;
Ok(config)
}
// Avoid: Manual error handling when ? works
fn load_and_parse_config_manual(path: &str) -> Result<Config, ConfigError> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(_) => return Err(ConfigError::FileNotFound { path: path.to_string() }),
};
let config = match parse_config(&contents) {
Ok(config) => config,
Err(e) => return Err(e),
};
match validate_config(&config) {
Ok(()) => Ok(config),
Err(e) => Err(e),
}
}
4. Implement From for Error Conversion
// Enable automatic conversion with ?
impl From<std::io::Error> for ConfigError {
fn from(error: std::io::Error) -> Self {
ConfigError::FileNotFound {
path: format!("I/O error: {}", error)
}
}
}
impl From<serde_json::Error> for ConfigError {
fn from(error: serde_json::Error) -> Self {
ConfigError::InvalidValue {
field: "json".to_string(),
value: error.to_string(),
expected: "valid JSON".to_string(),
}
}
}
5. Consider Using Library Crates for Complex Error Handling
// For applications: anyhow provides flexible error handling
use anyhow::{Context, Result};
fn application_function() -> Result<()> {
do_something().context("Failed to do something")?;
do_something_else().context("Failed to do something else")?;
Ok(())
}
// For libraries: thiserror provides structured error types
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LibraryError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Operation failed")]
OperationFailed(#[from] std::io::Error),
}
Rust's error handling system encourages explicit handling of failure cases, leading to more robust code. By using Result
types, custom error enums, and the ?
operator, you can build applications that gracefully handle errors and provide meaningful feedback to users and developers.