
M06 Error Handling
- 840 installs
- 1.3k repo stars
- Updated May 24, 2026
- zhanghandong/rust-skills
This is a copy of m06-error-handling by actionbook - installs and ranking accrue to the original listing.
m06-error-handling is a Rust skill that teaches library-specific error enums and application-level handling with thiserror and anyhow for developers who need Send+Sync, chainable errors in production Rust code.
About
m06-error-handling is a zhanghandong/rust-skills module that separates library error design from application error handling in Rust. The skill prescribes five library principles: define specific error types instead of anyhow, implement std::error::Error, expose matchable variants, chain source errors, and keep types Send + Sync for async. Worked examples use thiserror-derived enums such as DatabaseError with ConnectionFailed and query variants. Developers reach for m06-error-handling when shipping Rust crates or services where opaque errors frustrate downstream callers or async runtimes.
- 5 core principles for library error design
- Defines specific error types instead of using anyhow in libraries
- Implements std::error::Error with source chaining and Send + Sync
- Provides concrete error variants for easy matching by consumers
- Includes ready-to-use thiserror example with public Result alias
M06 Error Handling by the numbers
- 840 all-time installs (skills.sh)
- +9 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zhanghandong/rust-skills --skill m06-error-handlingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 840 |
|---|---|
| repo stars | ★ 1.3k |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 24, 2026 |
| Repository | zhanghandong/rust-skills ↗ |
How do you design Rust library error types?
Create robust, user-friendly error types and handling patterns in Rust libraries and applications.
Who is it for?
Rust developers publishing libraries or async services who need explicit, matchable error types.
Skip if: Quick scripts where a single anyhow::Error bubble is acceptable and no public API surface exists.
When should I use this skill?
A Rust crate needs custom error enums, error chains, or library-vs-app handling separation.
What you get
Typed error enums, source-chained variants, and library-vs-application handling guidance.
- Library error enum definitions
- Error-handling pattern guide
By the numbers
- Documents 5 core library error-handling principles
Files
Error Handling
Layer 1: Language Mechanics
Core Question
Is this failure expected or a bug?
Before choosing error handling strategy:
- Can this fail in normal operation?
- Who should handle this failure?
- What context does the caller need?
---
Error → Design Question
| Pattern | Don't Just Say | Ask Instead |
|---|---|---|
| unwrap panics | "Use ?" | Is None/Err actually possible here? |
| Type mismatch on ? | "Use anyhow" | Are error types designed correctly? |
| Lost error context | "Add .context()" | What does the caller need to know? |
| Too many error variants | "Use Box<dyn Error>" | Is error granularity right? |
---
Thinking Prompt
Before handling an error:
1. What kind of failure is this?
- Expected → Result<T, E>
- Absence normal → Option<T>
- Bug/invariant → panic!
- Unrecoverable → panic!
2. Who handles this?
- Caller → propagate with ?
- Current function → match/if-let
- User → friendly error message
- Programmer → panic with message
3. What context is needed?
- Type of error → thiserror variants
- Call chain → anyhow::Context
- Debug info → anyhow or tracing
---
Trace Up ↑
When error strategy is unclear:
"Should I return Result or Option?"
↑ Ask: Is absence/failure normal or exceptional?
↑ Check: m09-domain (what does domain say?)
↑ Check: domain-* (error handling requirements)| Situation | Trace To | Question |
|---|---|---|
| Too many unwraps | m09-domain | Is the data model right? |
| Error context design | m13-domain-error | What recovery is needed? |
| Library vs app errors | m11-ecosystem | Who are the consumers? |
---
Trace Down ↓
From design to implementation:
"Expected failure, library code"
↓ Use: thiserror for typed errors
"Expected failure, application code"
↓ Use: anyhow for ergonomic errors
"Absence is normal (find, get, lookup)"
↓ Use: Option<T>
"Bug or invariant violation"
↓ Use: panic!, assert!, unreachable!
"Need to propagate with context"
↓ Use: .context("what was happening")---
Quick Reference
| Pattern | When | Example |
|---|---|---|
Result<T, E> | Recoverable error | fn read() -> Result<String, io::Error> |
Option<T> | Absence is normal | fn find() -> Option<&Item> |
? | Propagate error | let data = file.read()?; |
unwrap() | Dev/test only | config.get("key").unwrap() |
expect() | Invariant holds | env.get("HOME").expect("HOME set") |
panic! | Unrecoverable | panic!("critical failure") |
Library vs Application
| Context | Error Crate | Why |
|---|---|---|
| Library | thiserror | Typed errors for consumers |
| Application | anyhow | Ergonomic error handling |
| Mixed | Both | thiserror at boundaries, anyhow internally |
Decision Flowchart
Is failure expected?
├─ Yes → Is absence the only "failure"?
│ ├─ Yes → Option<T>
│ └─ No → Result<T, E>
│ ├─ Library → thiserror
│ └─ Application → anyhow
└─ No → Is it a bug?
├─ Yes → panic!, assert!
└─ No → Consider if really unrecoverable
Use ? → Need context?
├─ Yes → .context("message")
└─ No → Plain ?---
Common Errors
| Error | Cause | Fix |
|---|---|---|
unwrap() panic | Unhandled None/Err | Use ? or match |
| Type mismatch | Different error types | Use anyhow or From |
| Lost context | ? without context | Add .context() |
cannot use ? | Missing Result return | Return Result<(), E> |
---
Anti-Patterns
| Anti-Pattern | Why Bad | Better |
|---|---|---|
.unwrap() everywhere | Panics in production | .expect("reason") or ? |
| Ignore errors silently | Bugs hidden | Handle or propagate |
panic! for expected errors | Bad UX, no recovery | Result |
| Box<dyn Error> everywhere | Lost type info | thiserror |
---
Related Skills
| When | See |
|---|---|
| Domain error strategy | m13-domain-error |
| Crate boundaries | m11-ecosystem |
| Type-safe errors | m05-type-driven |
| Mental models | m14-mental-model |
Error Handling: Library vs Application
Library Error Design
Principles
1. Define specific error types - Don't use anyhow in libraries 2. Implement std::error::Error - For compatibility 3. Provide error variants - Let users match on errors 4. Include source errors - Enable error chains 5. Be `Send + Sync` - For async compatibility
Example: Library Error Type
// lib.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("connection failed: {host}:{port}")]
ConnectionFailed {
host: String,
port: u16,
#[source]
source: std::io::Error,
},
#[error("query failed: {query}")]
QueryFailed {
query: String,
#[source]
source: SqlError,
},
#[error("record not found: {table}.{id}")]
NotFound { table: String, id: String },
#[error("constraint violation: {0}")]
ConstraintViolation(String),
}
// Public Result alias
pub type Result<T> = std::result::Result<T, DatabaseError>;
// Library functions
pub fn connect(host: &str, port: u16) -> Result<Connection> {
// ...
}
pub fn query(conn: &Connection, sql: &str) -> Result<Rows> {
// ...
}Library Usage of Errors
impl Database {
pub fn get_user(&self, id: &str) -> Result<User> {
let rows = self.query(&format!("SELECT * FROM users WHERE id = '{}'", id))?;
rows.first()
.cloned()
.ok_or_else(|| DatabaseError::NotFound {
table: "users".to_string(),
id: id.to_string(),
})
}
}---
Application Error Design
Principles
1. Use anyhow for convenience - Or custom unified error 2. Add context liberally - Help debugging 3. Log at boundaries - Don't log in libraries 4. Convert to user-friendly messages - For display
Example: Application Error Handling
// main.rs
use anyhow::{Context, Result};
use tracing::{error, info};
async fn run_server() -> Result<()> {
let config = load_config()
.context("failed to load configuration")?;
let db = Database::connect(&config.db_url)
.await
.context("failed to connect to database")?;
let server = Server::new(config.port)
.context("failed to create server")?;
info!("Server starting on port {}", config.port);
server.run(db).await
.context("server error")?;
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::init();
if let Err(e) = run_server().await {
error!("Application error: {:#}", e);
std::process::exit(1);
}
}Converting Library Errors
use mylib::DatabaseError;
async fn get_user_handler(id: &str) -> Result<Response> {
match db.get_user(id).await {
Ok(user) => Ok(Response::json(user)),
Err(DatabaseError::NotFound { .. }) => {
Ok(Response::not_found("User not found"))
}
Err(DatabaseError::ConnectionFailed { .. }) => {
error!("Database connection failed");
Ok(Response::internal_error("Service unavailable"))
}
Err(e) => {
error!("Database error: {}", e);
Err(e.into()) // Convert to anyhow::Error
}
}
}---
Error Handling Layers
┌─────────────────────────────────────┐
│ Application Layer │
│ - Use anyhow or unified error │
│ - Add context at boundaries │
│ - Log errors │
│ - Convert to user messages │
└─────────────────────────────────────┘
│
│ calls
▼
┌─────────────────────────────────────┐
│ Service Layer │
│ - Map between error types │
│ - Add business context │
│ - Handle recoverable errors │
└─────────────────────────────────────┘
│
│ calls
▼
┌─────────────────────────────────────┐
│ Library Layer │
│ - Define specific error types │
│ - Use thiserror │
│ - Include source errors │
│ - No logging │
└─────────────────────────────────────┘---
Practical Examples
HTTP API Error Response
use axum::{response::IntoResponse, http::StatusCode};
use serde::Serialize;
#[derive(Serialize)]
struct ErrorResponse {
error: String,
code: String,
}
enum AppError {
NotFound(String),
BadRequest(String),
Internal(anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, error, code) = match self {
AppError::NotFound(msg) => {
(StatusCode::NOT_FOUND, msg, "NOT_FOUND")
}
AppError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, msg, "BAD_REQUEST")
}
AppError::Internal(e) => {
tracing::error!("Internal error: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
"INTERNAL_ERROR",
)
}
};
let body = ErrorResponse {
error,
code: code.to_string(),
};
(status, axum::Json(body)).into_response()
}
}CLI Error Handling
use anyhow::{Context, Result};
use clap::Parser;
#[derive(Parser)]
struct Args {
#[arg(short, long)]
config: String,
}
fn main() {
if let Err(e) = run() {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
let config = std::fs::read_to_string(&args.config)
.context(format!("Failed to read config file: {}", args.config))?;
let parsed: Config = toml::from_str(&config)
.context("Failed to parse config file")?;
process(parsed)?;
println!("Done!");
Ok(())
}---
Testing Error Handling
Testing Error Cases
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_not_found_error() {
let result = db.get_user("nonexistent");
assert!(matches!(
result,
Err(DatabaseError::NotFound { table, id })
if table == "users" && id == "nonexistent"
));
}
#[test]
fn test_error_message() {
let err = DatabaseError::NotFound {
table: "users".to_string(),
id: "123".to_string(),
};
assert_eq!(err.to_string(), "record not found: users.123");
}
#[test]
fn test_error_chain() {
let io_err = std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused"
);
let err = DatabaseError::ConnectionFailed {
host: "localhost".to_string(),
port: 5432,
source: io_err,
};
// Check source is preserved
assert!(err.source().is_some());
}
}Testing with anyhow
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_context() -> anyhow::Result<()> {
let result = process("valid input")?;
assert_eq!(result, expected);
Ok(())
}
#[test]
fn test_error_context() {
let err = process("invalid")
.context("processing failed")
.unwrap_err();
// Check error chain contains expected text
let chain = format!("{:#}", err);
assert!(chain.contains("processing failed"));
}
}Error Handling Patterns
The ? Operator
Basic Usage
fn read_config() -> Result<Config, io::Error> {
let content = std::fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&content)?; // needs From impl
Ok(config)
}With Different Error Types
use std::error::Error;
// Box<dyn Error> for quick prototyping
fn process() -> Result<(), Box<dyn Error>> {
let file = std::fs::read_to_string("data.txt")?;
let num: i32 = file.trim().parse()?; // different error type
Ok(())
}Custom Conversion with From
#[derive(Debug)]
enum MyError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl From<std::io::Error> for MyError {
fn from(err: std::io::Error) -> Self {
MyError::Io(err)
}
}
impl From<std::num::ParseIntError> for MyError {
fn from(err: std::num::ParseIntError) -> Self {
MyError::Parse(err)
}
}
fn process() -> Result<i32, MyError> {
let content = std::fs::read_to_string("num.txt")?; // auto-converts
let num: i32 = content.trim().parse()?; // auto-converts
Ok(num)
}---
Error Type Design
Simple Enum Error
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigError {
NotFound,
InvalidFormat,
MissingField(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::NotFound => write!(f, "configuration file not found"),
ConfigError::InvalidFormat => write!(f, "invalid configuration format"),
ConfigError::MissingField(field) => write!(f, "missing field: {}", field),
}
}
}
impl std::error::Error for ConfigError {}Error with Source (Wrapping)
#[derive(Debug)]
pub struct AppError {
kind: AppErrorKind,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
#[derive(Debug, Clone, Copy)]
pub enum AppErrorKind {
Config,
Database,
Network,
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.kind {
AppErrorKind::Config => write!(f, "configuration error"),
AppErrorKind::Database => write!(f, "database error"),
AppErrorKind::Network => write!(f, "network error"),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.as_ref().map(|e| e.as_ref() as _)
}
}---
Using thiserror
Basic Usage
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataError {
#[error("file not found: {path}")]
NotFound { path: String },
#[error("invalid data format")]
InvalidFormat,
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
// Usage
fn load_data(path: &str) -> Result<Data, DataError> {
let content = std::fs::read_to_string(path)
.map_err(|_| DataError::NotFound { path: path.to_string() })?;
let num: i32 = content.trim().parse()?; // auto-converts with #[from]
Ok(Data { value: num })
}Transparent Wrapper
use thiserror::Error;
#[derive(Error, Debug)]
#[error(transparent)]
pub struct MyError(#[from] InnerError);
// Useful for newtype error wrappers---
Using anyhow
For Applications
use anyhow::{Context, Result, bail, ensure};
fn process_file(path: &str) -> Result<Data> {
let content = std::fs::read_to_string(path)
.context("failed to read config file")?;
ensure!(!content.is_empty(), "config file is empty");
let data: Data = serde_json::from_str(&content)
.context("failed to parse JSON")?;
if data.version < 1 {
bail!("unsupported config version: {}", data.version);
}
Ok(data)
}
fn main() -> Result<()> {
let data = process_file("config.json")
.context("failed to load configuration")?;
Ok(())
}Error Chain
use anyhow::{Context, Result};
fn deep_function() -> Result<()> {
std::fs::read_to_string("missing.txt")
.context("failed to read file")?;
Ok(())
}
fn middle_function() -> Result<()> {
deep_function()
.context("failed in deep function")?;
Ok(())
}
fn top_function() -> Result<()> {
middle_function()
.context("failed in middle function")?;
Ok(())
}
// Error output shows full chain:
// Error: failed in middle function
// Caused by:
// 0: failed in deep function
// 1: failed to read file
// 2: No such file or directory (os error 2)---
Option Handling
Converting Option to Result
fn find_user(id: u32) -> Option<User> { ... }
// Using ok_or for static error
fn get_user(id: u32) -> Result<User, &'static str> {
find_user(id).ok_or("user not found")
}
// Using ok_or_else for dynamic error
fn get_user(id: u32) -> Result<User, String> {
find_user(id).ok_or_else(|| format!("user {} not found", id))
}Chaining Options
fn get_nested_value(data: &Data) -> Option<&str> {
data.config
.as_ref()?
.nested
.as_ref()?
.value
.as_deref()
}
// Equivalent with and_then
fn get_nested_value(data: &Data) -> Option<&str> {
data.config
.as_ref()
.and_then(|c| c.nested.as_ref())
.and_then(|n| n.value.as_deref())
}---
Pattern: Result Combinators
map and map_err
fn parse_port(s: &str) -> Result<u16, ParseError> {
s.parse::<u16>()
.map_err(|e| ParseError::InvalidPort(e))
}
fn get_url(config: &Config) -> Result<String, Error> {
config.url()
.map(|u| format!("https://{}", u))
}and_then (flatMap)
fn validate_and_save(input: &str) -> Result<(), Error> {
validate(input)
.and_then(|valid| save(valid))
.and_then(|saved| notify(saved))
}unwrap_or and unwrap_or_else
// Default value
let port = config.port().unwrap_or(8080);
// Computed default
let port = config.port().unwrap_or_else(|| find_free_port());
// Default for Result
let data = load_data().unwrap_or_default();---
Pattern: Early Return vs Combinators
Early Return Style
fn process(input: &str) -> Result<Output, Error> {
let step1 = validate(input)?;
if !step1.is_valid {
return Err(Error::Invalid);
}
let step2 = transform(step1)?;
let step3 = save(step2)?;
Ok(step3)
}Combinator Style
fn process(input: &str) -> Result<Output, Error> {
validate(input)
.and_then(|s| {
if s.is_valid {
Ok(s)
} else {
Err(Error::Invalid)
}
})
.and_then(transform)
.and_then(save)
}When to Use Which
| Style | Best For |
|---|---|
Early return (?) | Most cases, clearer flow |
| Combinators | Functional pipelines, one-liners |
| Match | Complex branching on errors |
---
Panic vs Result
When to Panic
// 1. Unrecoverable programmer error
fn get_config() -> &'static Config {
CONFIG.get().expect("config must be initialized")
}
// 2. In tests
#[test]
fn test_parsing() {
let result = parse("valid").unwrap(); // OK in tests
assert_eq!(result, expected);
}
// 3. Prototype/examples
fn main() {
let data = load().unwrap(); // OK for quick examples
}When to Return Result
// 1. Any I/O operation
fn read_file(path: &str) -> Result<String, io::Error>
// 2. User input validation
fn parse_port(s: &str) -> Result<u16, ParseError>
// 3. Network operations
async fn fetch(url: &str) -> Result<Response, Error>
// 4. Anything that can fail at runtime
fn connect(addr: &str) -> Result<Connection, Error>---
Error Context Best Practices
Add Context at Boundaries
fn load_user_config(user_id: u64) -> Result<Config, Error> {
let path = format!("/home/{}/config.toml", user_id);
std::fs::read_to_string(&path)
.context(format!("failed to read config for user {}", user_id))?
// NOT: .context("failed to read file") // too generic
// ...
}Include Relevant Data
// Good: includes the problematic value
fn parse_age(s: &str) -> Result<u8, Error> {
s.parse()
.context(format!("invalid age value: '{}'", s))
}
// Bad: no context about what failed
fn parse_age(s: &str) -> Result<u8, Error> {
s.parse()
.context("parse error")
}Related skills
How it compares
Pick m06-error-handling over generic Rust tutorials when designing public crate error APIs, not when learning basic Result unwrap patterns.
FAQ
Should Rust libraries use anyhow?
m06-error-handling advises against anyhow in library crates. Libraries should define specific error enums with thiserror, implement std::error::Error, expose variants for matching, and chain source errors for debugging.
Why must Rust library errors be Send + Sync?
m06-error-handling requires Send + Sync error types so library failures propagate safely through async runtimes and thread pools. Without these bounds, error values cannot cross await points or shared tasks.
Is M06 Error Handling safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.