Error Handling
Rust error handling with Result, Option, the ? operator, and ecosystem crates anyhow and thiserror
You are an expert in Rust error handling for writing robust, recoverable code using Result, Option, and the broader error ecosystem.
## Key Points
- Use `anyhow::Result` in `main()` and binary application code for convenience.
- Use `thiserror` in library crates to provide structured error types callers can match on.
- Always add context with `.context()` or `.with_context()` when propagating errors — bare `?` loses the "what were we trying to do" information.
- Prefer `Result` over `panic!`. Reserve `panic!` for truly unrecoverable states and violated invariants.
- Use `.expect("reason")` over `.unwrap()` so panics carry intent.
- Return `Result` from `main()` for clean exit-code handling:
- **Overusing `.unwrap()`**: Every `.unwrap()` is a potential panic. Use `?`, `.unwrap_or_default()`, or `.expect()` with a reason.
- **Stringly-typed errors**: Returning `Result<T, String>` makes error matching impossible. Use typed errors.
- **Swallowing errors**: Ignoring `Result` with `let _ = fallible_call();` silently discards failures. Assign to `_` only when you consciously choose to discard.
- **Mixing `anyhow` in library public APIs**: Library callers cannot downcast or match on `anyhow::Error` easily. Keep `anyhow` for application code.
- **Not implementing `std::error::Error`**: Custom error types should implement `Error` (done automatically by `thiserror`) so they compose with the ecosystem.
## Quick Example
```rust
fn read_id(input: &str) -> Result<u64, String> {
input
.parse::<u64>()
.map_err(|e| format!("invalid ID '{input}': {e}"))
}
```
```rust
fn main() -> anyhow::Result<()> {
let config = load_config("app.toml")?;
run(config)?;
Ok(())
}
```skilldb get rust-skills/Error HandlingFull skill: 199 linesError Handling — Rust Programming
You are an expert in Rust error handling for writing robust, recoverable code using Result, Option, and the broader error ecosystem.
Core Philosophy
Rust treats errors as data, not as exceptional control flow. By encoding failure into the type system with Result<T, E> and absence with Option<T>, the compiler forces you to acknowledge and handle every failure path. This is not ceremony for its own sake -- it is the mechanism that makes Rust programs reliable without runtime exceptions or hidden panics.
The split between anyhow (for applications) and thiserror (for libraries) reflects a deeper design choice. Application code cares about reporting errors to humans with rich context. Library code cares about structuring errors so callers can programmatically react. Mixing these concerns -- using anyhow::Error in a library's public API or defining elaborate error enums in main.rs -- creates friction in both directions.
The ? operator is the linchpin. It makes error propagation as lightweight as exceptions in other languages while remaining explicit and visible. Every ? in your code is a documented early-return point. Adding .context() at each propagation site builds a causal chain that reads like a stack trace but carries semantic meaning: not just where the error occurred, but what the program was trying to do when it failed.
Anti-Patterns
- Sprinkling
.unwrap()throughout production code: Every.unwrap()is an implicitpanic!. In production, prefer?for propagation,.expect("reason")when an invariant truly cannot be violated, or.unwrap_or_default()when a fallback is acceptable. - Returning
Result<T, String>from library functions: String errors cannot be matched, downcasted, or composed. Callers are forced to parse error messages. Usethiserrorto define a proper enum that callers can handle programmatically. - Using
anyhowin library public APIs:anyhow::Erroris opaque to callers -- they cannot match on variants or recover selectively. Keepanyhowfor binary crates where the error's final destination is a log line or user message. - Bare
?without context: Propagating with?alone loses the "what were we trying to do" information. A chain of bare?calls produces an error like "No such file or directory" with no indication of which file or why. Always attach context with.context()or.with_context(). - Silently discarding
Resultwithlet _ =: Assigning aResultto_tells the compiler you intentionally ignore the outcome, but it also hides real failures. Only discard results when failure genuinely does not matter, and add a comment explaining why.
Overview
Rust has no exceptions. Errors are values represented by Result<T, E> for recoverable errors and panic! for unrecoverable ones. The Option<T> type handles the absence of a value. The ? operator provides ergonomic error propagation. The anyhow and thiserror crates are the standard ecosystem choices for application-level and library-level error handling respectively.
Core Concepts
Result and Option
// Result: operation that can fail
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
s.parse::<u16>()
}
// Option: value that may be absent
fn find_user(id: u64) -> Option<String> {
if id == 1 { Some("Alice".into()) } else { None }
}
The ? Operator
Propagates errors up the call stack, performing From conversion automatically:
use std::fs;
use std::io;
fn read_config() -> Result<String, io::Error> {
let path = fs::read_to_string("config.toml")?;
Ok(path)
}
Custom Error Types with thiserror
Use thiserror in libraries to define structured, convertible error types:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("invalid configuration: {field}")]
InvalidConfig { field: String },
#[error("not found: {0}")]
NotFound(String),
#[error(transparent)]
Unexpected(#[from] anyhow::Error),
}
Application Errors with anyhow
Use anyhow in application code (binaries) for easy ad-hoc error handling:
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {path}"))?;
let config: Config = toml::from_str(&content)
.context("failed to parse config TOML")?;
Ok(config)
}
Implementation Patterns
Combining Option and Result
fn get_env_port() -> Result<u16, anyhow::Error> {
let port_str = std::env::var("PORT")
.context("PORT not set")?;
let port = port_str.parse::<u16>()
.context("PORT is not a valid u16")?;
Ok(port)
}
Converting Between Option and Result
// Option -> Result
let value: Option<i32> = Some(42);
let result: Result<i32, &str> = value.ok_or("value was None");
// Result -> Option
let result: Result<i32, String> = Ok(42);
let option: Option<i32> = result.ok();
Error Type Hierarchies
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RepoError {
#[error("entity not found: {0}")]
NotFound(String),
#[error("duplicate key: {0}")]
Duplicate(String),
#[error(transparent)]
Internal(#[from] sqlx::Error),
}
#[derive(Debug, Error)]
pub enum ServiceError {
#[error(transparent)]
Repo(#[from] RepoError),
#[error("validation failed: {0}")]
Validation(String),
}
Iterating with Results
// Collect into Result<Vec<T>, E> — stops on first error
let numbers: Result<Vec<i32>, _> = vec!["1", "2", "three"]
.iter()
.map(|s| s.parse::<i32>())
.collect();
// Partition successes and failures
let (oks, errs): (Vec<_>, Vec<_>) = vec!["1", "two", "3"]
.iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
Mapping Errors
fn read_id(input: &str) -> Result<u64, String> {
input
.parse::<u64>()
.map_err(|e| format!("invalid ID '{input}': {e}"))
}
Best Practices
- Use
anyhow::Resultinmain()and binary application code for convenience. - Use
thiserrorin library crates to provide structured error types callers can match on. - Always add context with
.context()or.with_context()when propagating errors — bare?loses the "what were we trying to do" information. - Prefer
Resultoverpanic!. Reservepanic!for truly unrecoverable states and violated invariants. - Use
.expect("reason")over.unwrap()so panics carry intent. - Return
Resultfrommain()for clean exit-code handling:
fn main() -> anyhow::Result<()> {
let config = load_config("app.toml")?;
run(config)?;
Ok(())
}
Common Pitfalls
- Overusing
.unwrap(): Every.unwrap()is a potential panic. Use?,.unwrap_or_default(), or.expect()with a reason. - Stringly-typed errors: Returning
Result<T, String>makes error matching impossible. Use typed errors. - Swallowing errors: Ignoring
Resultwithlet _ = fallible_call();silently discards failures. Assign to_only when you consciously choose to discard. - Mixing
anyhowin library public APIs: Library callers cannot downcast or match onanyhow::Erroreasily. Keepanyhowfor application code. - Not implementing
std::error::Error: Custom error types should implementError(done automatically bythiserror) so they compose with the ecosystem.
Install this skill directly: skilldb add rust-skills
Related Skills
Async Rust
Async Rust programming with async/await, Tokio runtime, futures, and concurrent task patterns
Cargo Workspace
Cargo workspaces, project structure, dependency management, and multi-crate Rust project organization
Lifetimes
Rust lifetime annotations for ensuring reference validity and understanding the borrow checker
Ownership Borrowing
Rust ownership, borrowing, and move semantics for writing memory-safe code without a garbage collector
Pattern Matching
Rust pattern matching with match, if let, while let, destructuring, and advanced match patterns
Smart Pointers
Rust smart pointers Box, Rc, Arc, RefCell, and their combinations for heap allocation and shared ownership