| name | rust-error-handling |
| description | Use when designing error types, wrapping third-party errors, seeing repeated map_err calls with same error types, building modular error hierarchies with thiserror, choosing between custom errors vs anyhow::Result, or writing Result-returning functions in Rust. |
Rust Error Handling
Core principle: Start simple. Add structure when pain emerges.
Don't Over-Engineer
fn read_config(path: &Path) -> Result<Config, std::io::Error> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|e| std::io::Error::other(e))
}
fn load_app() -> anyhow::Result<App> {
let config = read_config(Path::new("config.toml"))?;
let db = connect_db(&config.db_url)?;
Ok(App { config, db })
}
Only create custom error types when you feel the friction.
When to Create Custom Error Type
Signal: Repeated map_err with same types across multiple functions.
fn fetch_user() -> Result<User, ApiError> {
let data = db.query().map_err(|e| ApiError::Internal(e.to_string()))?;
let parsed = serde_json::from_str(&data).map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(parsed)
}
#[derive(Debug, thiserror::Error)]
pub enum RepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("parse error: {0}")]
Parse(#[from] serde_json::Error),
}
fn fetch_user() -> Result<User, RepoError> {
let data = db.query()?;
let parsed = serde_json::from_str(&data)?;
Ok(parsed)
}
When unwrap/expect is Appropriate
Fail-fast at startup. Propagate at runtime.
| Phase | Approach |
|---|
main() initialization | expect("reason") |
| Config/pool setup | expect("reason") |
| Request handling | Result + ? |
| Library code | Result + ? |
fn main() {
let config = Config::from_env().expect("Failed to load config");
let pool = Pool::connect(&config.database_url)
.await
.expect("Database connection required");
run_server(config, pool).await.unwrap();
}
Static vs Runtime Context
Inner data only for runtime information.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Config file not found")]
NotFound,
#[error("Invalid config: {0}")]
InvalidConfig(String),
#[error("Missing field: {0}")]
MissingField(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
Data Firewall (Public APIs Only)
Don't leak third-party errors from public crate APIs.
pub fn get_user(id: &str) -> Result<User, sqlx::Error>
pub fn get_user(id: &str) -> Result<User, DbError>
Internal modules can use third-party errors directly.
Unexpected Errors
Use anyhow::Error for truly unexpected errors with context.
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("unexpected error: {0}")]
Unexpected(#[from] anyhow::Error),
}
something().map_err(|e| anyhow::anyhow!(e).context("context"))?;
Logging
| Error Type | Log Level |
|---|
| Business (InvalidEmail) | INFO/WARN |
| System (ConnectionFailed) | ERROR |
| Unexpected | ERROR + trace |
Log once at the edge, not at each layer.