erra
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 = read
.map_err?;
// anyhow::Context: ergonomic, but E is gone forever
let data = read.context?;
// callers must downcast_ref::<io::Error>() -- not compiler-checked
// thiserror: correct, but one new enum variant per call site
ReadFailed ,
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 ResultExt;
use fs;
One import. One method. E is preserved. ? works unchanged. No new types.
Installation
[]
= "0.2"
Usage
Static annotation
use ResultExt;
use io;
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 ResultExt;
use io;
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 ResultExt;
use io;
match process
e.source is a public field of type E. Direct access, no method call, no runtime cast.
Chaining
use ResultExt;
use io;
let err = outer.unwrap_err;
println!;
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 ResultExt;
use io;
let err = Err::
.annotate
.unwrap_err;
let original: Error = err.into_source;
assert_eq!;
Transforming the source type
use ;
use io;
;
let io_err: =
Err::
.annotate
.unwrap_err;
let db_err: = io_err.map;
assert_eq!;
Composing with thiserror
Use thiserror to define structured error enums at module boundaries and erra to annotate call
sites between them:
use ResultExt;
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 ResultExt;
Migration from anyhow::Context
Only the method name changes. The return type becomes strictly more informative:
// Before
use Context;
let file = read.context?;
// return type: anyhow::Result<T> -- E is erased
// After
use ResultExt;
let file = read.annotate?;
// 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)
= "0.2"
All functionality available.
alloc only
For targets with a global allocator but no std:
= { = "0.2", = false, = ["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:
= { = "0.2", = 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 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
| 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 Result;
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
anyhowthroughout a large codebase.
When to use erra
- Writing a library whose public API must not impose
anyhow::Erroron dependents. - Targeting embedded or
no_stdenvironments. - Callers need to match on
Eat 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.