erra 0.2.0

Zero-dependency, type-preserving error annotation for Result
Documentation
  • Coverage
  • 100%
    9 out of 9 items documented8 out of 8 items with examples
  • Size
  • Source code size: 116.92 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 595.13 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 2s Average build duration of successful builds.
  • all releases: 4s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • ZaudRehman/erra
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • ZaudRehman

erra

Crates.io Docs.rs CI MSRV: 1.75.0 License: MIT OR Apache-2.0

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

erra sits between raw ? propagation and full frameworks like anyhow or eyre. Annotate any Result with a human-readable string at the call site, keep E fully typed and pattern-matchable, and pay zero cost on the Ok path — with no transitive dependencies.


The Problem

The ? operator propagates errors but strips all call-site context. A production incident that surfaces:

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

tells you what failed, not where. The standard workarounds each carry a cost:

// map_err: verbose and erases E into String
let data = fs::read(&path)
    .map_err(|e| format!("failed to read config at {path}: {e}"))?;

// anyhow::Context: ergonomic, but E is gone forever
let data = fs::read(&path).context("failed to read config")?;
// callers must downcast_ref::<io::Error>() -- not compiler-checked

// thiserror: correct, but one new enum variant per call site
#[error("failed to read config at {path}: {source}")]
ReadFailed { path: PathBuf, source: io::Error },

None of these cover the common case: annotate the error with context, keep the type, propagate with ?, without a new enum variant.


The Solution

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

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

One import. One method. E is preserved. ? works unchanged. No new types.


Installation

[dependencies]
erra = "0.2"

Usage

Static annotation

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

fn read_config(path: &str) -> erra::Result<String, io::Error> {
    std::fs::read_to_string(path).annotate("reading application config")
}

annotate takes a &'static str. The string lives in the binary's read-only segment and is never heap-allocated. On the Ok path, no work is done.

Dynamic annotation

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

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

The closure is called only on the Err path. On Ok, there is no closure call, no format!, and no allocation.

Pattern matching without downcast

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

fn process(path: &str) -> erra::Result<(), 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"),
        io::ErrorKind::PermissionDenied => eprintln!("permission denied"),
        _ => eprintln!("io error: {e}"),
    },
}

e.source is a public field of type E. Direct access, no method call, no runtime cast.

Chaining

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

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

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

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

let err = outer().unwrap_err();
println!("{err}");

Each annotation layer wraps the previous. Display presents them outermost-first. The std::error::Error::source() chain is fully traversable by any compliant error reporter.

Recovering the original error

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

let err = Err::<(), io::Error>(io::Error::from(io::ErrorKind::TimedOut))
    .annotate("connect to upstream")
    .unwrap_err();

let original: io::Error = err.into_source();
assert_eq!(original.kind(), io::ErrorKind::TimedOut);

Transforming the source type

use erra::{Error, ResultExt};
use std::io;

#[derive(Debug)]
struct DbError(String);

impl std::fmt::Display for DbError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "db: {}", self.0)
    }
}

let io_err: Error<io::Error> =
    Err::<(), _>(io::Error::from(io::ErrorKind::NotFound))
        .annotate("reading row from disk")
        .unwrap_err();

let db_err: Error<DbError> = io_err.map(|e| DbError(e.to_string()));
assert_eq!(db_err.context(), "reading row from disk");

Composing with thiserror

Use thiserror to define structured error enums at module boundaries and erra to annotate call sites between them:

use erra::ResultExt;

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

fn load() -> Result<String, AppError> {
    std::fs::read_to_string("app.toml")
        .annotate("loading startup config")
        .map_err(AppError::Config)
}

erra itself requires no proc-macro. The thiserror dependency above belongs to the consuming crate.


Composing with anyhow

erra::Error<E> implements std::error::Error, so it converts into anyhow::Error via the standard From path. No adapter needed:

use erra::ResultExt;

fn annotated() -> erra::Result<String, std::io::Error> {
    std::fs::read_to_string("app.toml").annotate("reading config")
}

fn app_main() -> anyhow::Result<()> {
    let contents = annotated().map_err(anyhow::Error::from)?;
    println!("{contents}");
    Ok(())
}

Migration from anyhow::Context

Only the method name changes. The return type becomes strictly more informative:

// Before
use anyhow::Context;
let file = fs::read(&path).context("reading config")?;
// return type: anyhow::Result<T> -- E is erased

// After
use erra::ResultExt;
let file = fs::read(&path).annotate("reading config")?;
// return type: erra::Result<T, io::Error> -- E is preserved

Migration is incremental. Each changed function is a self-contained diff with no impact on adjacent code.


Feature Flags

Flag Default Enables
std yes std::error::Error impl; implies alloc
alloc implied by std annotate_with, Cow::Owned, Error::new_owned

Default (std)

erra = "0.2"

All functionality available.

alloc only

For targets with a global allocator but no std:

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

annotate_with and new_owned are available. std::error::Error is not implemented.

no_std, no allocator

For bare-metal targets with no heap:

erra = { version = "0.2", default-features = false }

Only .annotate("static string") is available. No heap allocation anywhere in erra. Display and Debug work via core::fmt.

cargo check --target thumbv6m-none-eabi --no-default-features

API Reference

ResultExt trait

use erra::ResultExt;
Method Signature Notes
annotate fn annotate(self, msg: &'static str) -> erra::Result<T, E> Zero allocation. Always available.
annotate_with fn annotate_with<F: FnOnce() -> String>(self, f: F) -> erra::Result<T, E> Closure skipped on Ok. Requires alloc.

Error<E> type

pub struct Error<E> {
    pub context: core::borrow::Cow<'static, str>,
    pub source: E,
}
Method Signature Notes
new fn new(context: &'static str, source: E) -> Self Zero allocation constructor.
new_owned fn new_owned(context: String, source: E) -> Self Requires alloc or std.
context fn context(&self) -> &str Borrows the annotation string.
into_source fn into_source(self) -> E Consumes self, returns E.
map fn map<F, E2>(self, f: F) -> Error<E2> Transforms E, preserves context.

Trait impls

Trait Condition
Display E: Display
Debug E: Debug
Clone E: Clone
PartialEq E: PartialEq
Eq E: Eq
std::error::Error E: std::error::Error + 'static and feature std
Send E: Send (auto-trait)
Sync E: Sync (auto-trait)
From<E> Never — context must always be explicit

Convenience alias

use erra::Result;

fn example() -> Result<(), std::io::Error> {
    Ok(())
}

Result<T, E> is a shorthand for core::result::Result<T, erra::Error<E>>.


Comparison

erra anyhow::Context thiserror error-context
Type preserved yes no (erased) yes yes
Pattern match on E compile-time runtime downcast yes yes
Zero dependencies yes no no (proc-macro) yes
no_std yes no no partial
No proc-macro yes yes no yes
Backtrace no yes no no
Actively maintained yes yes yes no (abandoned)
Library-safe API yes no yes yes

When to use anyhow instead

  • Writing application glue where callers never need to match on specific error variants.
  • Backtrace capture is required.
  • Already committed to anyhow throughout a large codebase.

When to use erra

  • Writing a library whose public API must not impose anyhow::Error on dependents.
  • Targeting embedded or no_std environments.
  • Callers need to match on E at compile time.
  • Zero transitive dependencies are a hard requirement.

Performance

In a release build with LTO, .annotate("msg") on Ok(v) is intended to be a zero-cost identity pass-through. annotate_with defers work until the Err path and does not invoke its closure on the Ok path.

The exact microbenchmark numbers are intentionally omitted from the README so they do not age faster than the code.

cargo bench
cargo bench -- ok_path

Safety

#![forbid(unsafe_code)]

erra contains zero unsafe blocks. cargo geiger reports zero unsafe lines.


MSRV

Rust 1.75.0. No nightly features. No GATs. No RPITIT.

MSRV increases are treated as minor version bumps and are documented in CHANGELOG.md. CI tests the declared minimum on every push.


Testing

cargo test --all-features                           # all features
cargo test --no-default-features                    # no_std static path
cargo test --no-default-features --features alloc   # alloc, no std
cargo clippy --all-features -- -D warnings          # zero warnings
cargo doc --all-features --no-deps                  # docs check
cargo check --target thumbv6m-none-eabi --no-default-features
cargo geiger                                       # safety audit
cargo bench                                        # benchmarks

Contributing

Issues and pull requests are welcome at github.com/ZaudRehman/erra.

For bugs, include the toolchain version (rustc --version), feature flags, and a minimal reproducer. For API proposals, open a discussion issue first with a written rationale covering the use case, alternatives considered, and impact on existing consumers.


Author

Zaud Rehman: @ZaudRehman · @RehmanZaud


License

Licensed under either of:

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you shall be dual-licensed as above, without any additional terms or conditions.