Nicolas Marshall

Nicolas
Marshall

All posts

Rust SNAFU Errors: Best Practices

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 Display and Source derivation
  • Clean Result<T, MyError> types without manual impl

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 snafu for 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