Rust Error Handling: From Result to Custom Types
Rust Error Handling: From Result to Custom Types
Rust's error handling forces you to think about failure modes. This isn't a flaw. It's a feature that catches bugs at compile time instead of in production. This post explores error handling patterns from basic to advanced.
The Foundation: Result
All error handling in Rust starts with
Result:
pub enum Result<T, E> {
Ok(T),
Err(E),
}
A function either succeeds with a value or fails with an error. No exceptions. No null. No surprises.
fn parse_age(input: &str) -> Result<u32, std::num::ParseIntError> {
input.trim().parse::<u32>()
}
fn main() {
match parse_age("42") {
Ok(age) => println!("Age: {}", age),
Err(e) => eprintln!("Failed to parse: {}", e),
}
}
This is explicit. Anyone reading this code knows it can fail. The compiler forces you to handle both cases.
The Question Mark Operator
Repeatedly matching Result is verbose. The
? operator unwraps or returns early:
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let trimmed = contents.trim().to_string();
Ok(trimmed)
}
This is equivalent to:
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e.into()),
};
let trimmed = contents.trim().to_string();
Ok(trimmed)
}
The
? operator is much cleaner. Use it liberally in functions that return Result.
Custom Error Types
Generic error types work but lack context. Define custom error types for clarity:
use std::fmt;
#[derive(Debug)]
enum ConfigError {
FileNotFound(String),
InvalidFormat(String),
MissingField(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::FileNotFound(path) => {
write!(f, "Configuration file not found: {}", path)
}
ConfigError::InvalidFormat(reason) => {
write!(f, "Invalid configuration format: {}", reason)
}
ConfigError::MissingField(field) => {
write!(f, "Missing required field: {}", field)
}
}
}
}
impl std::error::Error for ConfigError {}
Now your error type describes the domain:
fn load_config(path: &str) -> Result<Config, ConfigError> {
let contents = std::fs::read_to_string(path)
.map_err(|_| ConfigError::FileNotFound(path.to_string()))?;
let config = parse_toml(&contents)
.map_err(|e| ConfigError::InvalidFormat(e.to_string()))?;
Ok(config)
}
Callers can pattern match on specific errors:
match load_config("app.toml") {
Ok(config) => run_app(config),
Err(ConfigError::FileNotFound(path)) => {
eprintln!("Please create {}", path);
std::process::exit(1);
}
Err(ConfigError::InvalidFormat(reason)) => {
eprintln!("Fix the config: {}", reason);
std::process::exit(1);
}
Err(ConfigError::MissingField(field)) => {
eprintln!("Add {} to your config", field);
std::process::exit(1);
}
}
Error Context with anyhow
For libraries, use the
anyhow crate to add context:
[dependencies]
anyhow = "1.0"
thiserror = "1.0"
use anyhow::{Context, Result};
fn connect_db(url: &str) -> Result<Database> {
let db = Database::connect(url)
.context("Failed to connect to database")?;
db.ping()
.context("Database connection failed health check")?;
Ok(db)
}
fn main() -> Result<()> {
let db = connect_db("postgres://...")?;
// ... rest of code
Ok(())
}
When this fails, the user sees:
Error: Failed to connect to database Caused by: 0: connection refused 1: No such file or directory
Context makes debugging trivial.
The thiserror
Crate
thiserrorFor defining error types elegantly:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Request timeout after {0}s")]
Timeout(u64),
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Server error: {status} {message}")]
ServerError { status: u16, message: String },
#[error(transparent)]
Io(#[from] std::io::Error),
}
No need to manually implement
Display and Error. #[derive(Error)] does it for you.
The
#[from] attribute auto-implements From for ApiError , allowing ? to convert automatically:
fn read_file(path: &str) -> Result<String, ApiError> {
// std::io::Error converts to ApiError automatically
Ok(std::fs::read_to_string(path)?)
}
Pattern: Early Returns
Use early returns to avoid deep nesting:
// Bad: pyramid of doom
fn process_user(id: u32) -> Result<User> {
match get_user(id) {
Ok(user) => {
match validate_user(&user) {
Ok(_) => {
match update_permissions(&user) {
Ok(updated) => Ok(updated),
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
}
// Good: early returns with ?
fn process_user(id: u32) -> Result<User> {
let user = get_user(id)?;
validate_user(&user)?;
let updated = update_permissions(&user)?;
Ok(updated)
}
The second version is much clearer. The happy path is obvious.
Testing Error Cases
Test that functions fail appropriately:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_invalid_age() {
let result = parse_age("not a number");
assert!(result.is_err());
match result {
Err(e) => {
assert_eq!(e.kind(), std::num::IntErrorKind::PosOverflow);
}
Ok(_) => panic!("Should have failed"),
}
}
#[test]
fn test_missing_config_file() {
let result = load_config("/nonexistent/path.toml");
assert!(matches!(result, Err(ConfigError::FileNotFound(_))));
}
}
Don't just test the happy path. Test that errors are reported correctly.
Conclusion
Rust error handling is explicit by design. The benefits:
- Compile-time safety: Can't forget to handle errors
- Clear error types: Understand what can fail
- Context: Error messages tell you why
- Composability: Combine functions with
?
Start with
Result. Add custom error types as your system grows. Use anyhow for libraries and thiserror for error definitions. Test both success and failure paths.
Rust's error handling philosophy reduces production bugs. Embrace it.