Error handling in Rust can be verbose. The snafu crate cuts through the boilerplate while keeping things explicit. Here’s how to use it well.
Why SNAFU?
thiserror is great for libraries, but snafu shines when you want:
- Context annotations that attach meaning to errors
- Automatic
DisplayandSourcederivation - Clean
Result<T, MyError>types without manualimpl
The basics
use snafu::Snafu;
#[derive(Debug, Snafu)]
enum ConfigError {
#[snafu(display("failed to read config at {path}"))]
ReadFailed { path: String, source: std::io::Error },
#[snafu(display("invalid value for key '{key}': {value}"))]
InvalidValue { key: String, value: String },
#[snafu(display("missing required field '{field}'"))]
MissingField { field: String },
}
Each variant maps to one failure mode. The #[snafu(display(...))] macro generates Display and wires up the source error.
Use context() at the call site
use snafu::ResultExt;
fn load_config(path: &str) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path)
.context(ReadFailedSnafu { path })?;
let config: Config = serde_json::from_str(&content)
.context(InvalidValueSnafu { key: "root" })?;
Ok(config)
}
context() converts any error into your custom enum variant. The *Snafu suffix types are auto-generated by the derive macro.
Prefer enums over single-struct errors
A single error struct with a kind field works, but enums are clearer:
// Avoid: one struct with a catch-all
#[derive(Debug, Snafu)]
#[snafu(module)]
pub struct Error {
#[snafu(source)]
pub error: Box<dyn std::error::Error>,
#[snafu(display("{}", kind))]
pub kind: ErrorKind,
}
// Prefer: explicit variants
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("io error"))]
Io { source: std::io::Error },
#[snafu(display("parse error at {line}:{col}"))]
Parse { line: usize, col: usize, source: serde_json::Error },
}
Enums make it impossible to forget a case in match arms.
Keep context fields minimal
Only include the context you need for a useful error message:
// Good: just what's needed
#[snafu(display("timeout after {ms}ms"))]
Timeout { ms: u64 },
// Avoid: dumping everything
#[snafu(display("timeout after {ms}ms for {url} with {retries} retries"))]
Timeout { ms: u64, url: String, retries: usize, config: Config },
Use whatever!() for truly unexpected errors
When you need to erase a type in prototyping or for errors that shouldn’t happen:
use snafu::whatever;
fn risky_operation() -> Result<(), WhateverError> {
let result = some_untyped_operation()
.whatever_context("unexpected failure")?;
Ok(())
}
Don’t overuse this — it hides the error type from callers.
Convert between error types
impl From<ConfigError> for AppError {
fn from(e: ConfigError) -> Self {
AppError::Config { source: e }
}
}
Or use #[snafu(from)] for automatic conversions:
#[derive(Debug, Snafu)]
enum AppError {
#[snafu(from)]
Config { source: ConfigError },
#[snafu(display("network error"))]
Network { source: reqwest::Error },
}
Summary
- Use
snafufor application errors with context - One enum variant per failure mode
- Call
.context(SnafuType { fields })at the call site - Keep context fields minimal
- Reserve
whatever!()for truly unexpected cases
