Skip to main content

Crate erra

Crate erra 

Source
Expand description

§erra

Zero-dependency, no_std-compatible, type-preserving error annotation for Result<T, E>.

§The Problem

The ? operator propagates errors faithfully but strips every shred of call-site context. A production incident that produces:

Os { code: 2, kind: NotFound, message: "No such file or directory" }

tells you what failed but nothing about where, which file, or which layer of your call stack produced it. Diagnosing it is slow and expensive.

The standard workarounds each carry a real cost:

  • map_err + format! — verbose, repeated at every call site, and erases the typed E into a String.
  • anyhow::Context — ergonomic, but type-erasing. Once an error enters anyhow::Error, the only structured recovery path is downcast_ref::<E>() — a runtime operation the compiler cannot verify. Libraries cannot expose anyhow::Error in their public APIs without forcing the same choice on all dependents.
  • thiserror enum variants — correct at public API boundaries but impractically verbose for internal call-site annotation, and adds a proc-macro compile dependency.

erra fills the gap: annotate any Result with a string label at the call site, keep E fully typed and pattern-matchable at compile time, and pay zero cost on the Ok path.

§Quickstart

Add to Cargo.toml:

[dependencies]
erra = "0.1"

Import the extension trait and annotate:

use erra::ResultExt;
use std::fs;

fn load_config(path: &str) -> Result<String, erra::Error<std::io::Error>> {
    let contents = fs::read_to_string(path)
        .annotate("reading application config")?;
    Ok(contents)
}

Dynamic context — the closure is not invoked if the result is Ok:

use erra::ResultExt;
use std::fs;

fn load_named(path: &str) -> Result<String, erra::Error<std::io::Error>> {
    fs::read_to_string(path)
        .annotate_with(|| format!("reading config at {path}"))
}

Pattern-matching on the original typed error — no downcast needed:

use erra::ResultExt;
use std::io;

fn process(path: &str) -> Result<(), erra::Error<io::Error>> {
    std::fs::read_to_string(path).annotate("process: read")?;
    Ok(())
}

match process("missing.toml") {
    Ok(_) => {}
    Err(e) => match e.source.kind() {
        io::ErrorKind::NotFound => eprintln!("file not found"),
        _ => eprintln!("other io error: {e}"),
    },
}

§Chaining

Multiple annotations compose naturally. Each layer wraps the previous, producing Error<Error<E>>. The source() chain is fully traversable by any std::error::Error-compliant reporter:

use erra::ResultExt;
use std::io;

fn inner() -> Result<(), io::Error> {
    Err(io::Error::from(io::ErrorKind::NotFound))
}

fn middle() -> Result<(), erra::Error<io::Error>> {
    inner().annotate("middle: reading file")
}

fn outer() -> Result<(), erra::Error<erra::Error<io::Error>>> {
    middle().annotate("outer: loading config")
}

let err = outer().unwrap_err();
// Prints: "outer: loading config: middle: reading file: entity not found"
println!("{err}");

§Composing with thiserror

erra and thiserror solve different layers. Use thiserror to define structured error enums at module boundaries; use erra to annotate call sites between those boundaries without declaring a new variant per site:

use erra::ResultExt;

#[derive(Debug)]
enum AppError {
    Config(erra::Error<std::io::Error>),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Config(e) => write!(f, "config error: {e}"),
        }
    }
}

impl std::error::Error for AppError {}

§Compared to anyhow

Concernerraanyhow
Error type preserved✗ (erased to dyn Error)
Pattern matching on E✓ compile-time✗ runtime downcast
Zero dependencies
no_std support
Backtrace capture
Library-safe public API

Choose anyhow when: you are writing application top-level glue code, you need backtrace capture, or you have no interest in matching on specific error variants after the fact.

Choose erra when: you are writing a library, an embedded crate, or any code where E must remain statically matchable by the caller.

Note: erra::Error<E> converts naturally into anyhow::Error via anyhow::Error::from(err) — since erra::Error<E>: std::error::Error — so the two can coexist incrementally in the same codebase.

§no_std Usage

Disable default features for the zero-allocation static-string path only. No annotate_with, no heap allocation anywhere:

[dependencies]
erra = { version = "0.1", default-features = false }

Enable dynamic annotation on targets with a global allocator but no std (WASM, custom OS kernels, etc.):

[dependencies]
erra = { version = "0.1", default-features = false, features = ["alloc"] }

§Feature Flags

FlagDefaultEnables
stdyesstd::error::Error impl; implies alloc
allocimplied by stdannotate_with, Cow::Owned, Error::new_owned

§MSRV

Rust 1.60.0. No nightly features. No const generics. No GATs.

Structs§

Error
A type-preserving annotated error.

Traits§

ResultExt
Extension trait that adds type-preserving error annotation to any Result<T, E>.