erra
Zero-dependency, no_std-compatible, type-preserving error annotation
for Result<T, E>.
erra fills the gap between raw ? propagation and full error-handling
frameworks like anyhow or eyre. It lets you annotate any Result with
a human-readable string at the call site, keep E fully typed and
pattern-matchable by the compiler, and pay zero cost on the Ok path —
all without pulling in a single transitive dependency.
The Problem
The ? operator propagates errors faithfully 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, nothing about where. The standard workarounds each carry a real cost:
// Pattern A — map_err: verbose, repeated, erases E into String
let data = read
.map_err?;
// Pattern B — anyhow::Context: ergonomic, but E is gone forever
let data = read.context?;
// downstream callers must downcast_ref::<io::Error>() — not compiler-checked
// Pattern C — thiserror variant: correct, but one new enum variant per call site
ReadFailed ,
None of these serve the common case: annotate this error with where it
came from, keep the error type, propagate with ?, without declaring a
new enum variant.
The Solution
use ResultExt;
use fs;
One import. One method. E is preserved. The ? operator works
unchanged. No new types declared.
Installation
[]
= "0.1"
Usage
Static annotation — zero allocation
use ResultExt;
use io;
annotate takes a &'static str. The string is baked into the binary's
read-only segment and never heap-allocated. On the Ok path, no work is
done at all.
Dynamic annotation — closure not called on Ok
use ResultExt;
use io;
The closure is invoked only on the Err path. On Ok, no closure
call, no format!, no allocation. This is a performance contract.
Pattern matching on the original type — no downcast
use ResultExt;
use io;
match process
e.source is a public field of type E. Direct field access, no method
call, no runtime cast. The compiler checks the match exhaustively.
Chaining — multiple annotation layers
use ResultExt;
use io;
let err = outer.unwrap_err;
println!;
// outer: loading config: middle: reading file: entity not found
Each annotation layer wraps the previous. The Display output 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;
// Discard the annotation, recover E.
let original: Error = err.into_source;
assert_eq!;
Transforming the source type at a module boundary
use ;
use io;
;
let io_err: =
Err::
.annotate
.unwrap_err;
// Convert to domain error type, context survives.
let db_err: = io_err.map;
assert_eq!;
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:
use ResultExt;
No proc-macro is required to use erra itself. The thiserror dependency
above is in the consuming crate — erra remains zero-dependency.
Composing with anyhow
erra::Error<E> implements std::error::Error, so it converts into
anyhow::Error via the standard anyhow::Error::from(err) path.
No special adapter is needed:
use ResultExt;
Migration from anyhow::Context
Call-by-call migration. 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: Result<T, erra::Error<io::Error>> — E is preserved
Functions that previously returned anyhow::Result<T> can be migrated
incrementally. Each changed function is a standalone 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.1"
All functionality available.
alloc only — no std
For targets with a global allocator but no std (WASM, custom OS
kernels, some embedded targets):
= { = "0.1", = false, = ["alloc"] }
annotate_with and new_owned available. std::error::Error not
implemented (requires std).
no_std, no allocator
For bare-metal embedded targets with no heap at all:
= { = "0.1", = false }
Only .annotate("static string") is available. No annotate_with,
no new_owned, no heap allocation anywhere in erra. Display and
Debug work via core::fmt.
Verify embedded target compatibility:
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) -> Result<T, Error<E>> |
Zero allocation. Always available. |
annotate_with |
fn annotate_with<F>(self, f: F) -> Result<T, Error<E>> where F: FnOnce() -> String |
Closure not called on Ok. Requires alloc or std. |
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 on Error<E>
| 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 |
Comparison
erra |
anyhow::Context |
thiserror |
error-context |
|
|---|---|---|---|---|
| Type preserved | ✓ | ✗ erased | ✓ | ✓ |
Pattern match on E |
✓ compile-time | ✗ runtime downcast | ✓ | ✓ |
| Zero dependencies | ✓ | ✗ | ✗ proc-macro | ✓ |
no_std |
✓ | ✗ | ✗ | partial |
| No proc-macro | ✓ | ✓ | ✗ | ✓ |
| Backtrace | ✗ | ✓ | ✗ | ✗ |
| Actively maintained | ✓ | ✓ | ✓ | ✗ abandoned |
| Library-safe API | ✓ | ✗ | ✓ | ✓ |
When to choose anyhow instead
- You are writing application top-level glue and callers will never need to match on specific error variants.
- You need backtrace capture.
- You are already committed to
anyhowthroughout a large application codebase and the type erasure is not a problem.
When to choose erra
- You are writing a library and your public API must not impose
anyhow::Erroron dependents. - You are writing embedded or
no_stdcode with no room foranyhow's dependency weight. - You need callers to be able to match on
Eat compile time. - You want zero transitive dependencies —
erra's entire audit surface iserraitself.
Performance
In a release build with LTO, .annotate("msg") on Ok(v) compiles to
a zero-cost identity pass-through. The following is representative output
from cargo bench on an Apple M2 (results vary by platform):
ok_path/bare_unwrap time: [312.45 ps 313.02 ps 313.67 ps]
ok_path/annotate_static_on_ok time: [312.89 ps 313.44 ps 314.11 ps]
ok_path/annotate_with_closure_on_ok time: [313.01 ps 313.58 ps 314.22 ps]
err_path/bare_unwrap_err time: [1.4821 ns 1.4897 ns 1.4981 ns]
err_path/annotate_static_on_err time: [2.1043 ns 2.1119 ns 2.1204 ns]
err_path/annotate_with_closure_on_err time: [18.334 ns 18.412 ns 18.498 ns]
The three ok_path results are statistically indistinguishable. The
Err path cost is proportionate: static annotation adds one
Cow::Borrowed construction; dynamic annotation adds a format! and
a heap allocation.
Run benchmarks yourself:
cargo bench
cargo bench -- ok_path # run a single group
Safety
#![forbid(unsafe_code)]
erra contains zero unsafe blocks. cargo geiger reports zero unsafe
lines. The entire implementation is safe Rust.
MSRV
Rust 1.60.0. No nightly features. No const generics beyond WriteBuf
in the test suite (1.51). No GATs. No RPITIT.
MSRV bumps are treated as minor version increments following the convention for pre-1.0 crates. MSRV is tested in CI against the declared minimum toolchain.
Running the Test Suite
# Default — all features
cargo test --all-features
# no_std static path only
cargo test --no-default-features
# alloc path, no std
cargo test --no-default-features --features alloc
# Lint — must produce zero warnings
cargo clippy --all-features -- -D warnings
# Docs — must build without errors or warnings
cargo doc --all-features --no-deps
# Embedded target compile check
cargo check --target thumbv6m-none-eabi --no-default-features
# Safety audit
cargo geiger
# Benchmarks
cargo bench
Contributing
Issues and pull requests are welcome at github.com/ZaudRehman/erra.
For bugs, please include the Rust toolchain version (rustc --version),
the feature flags in use, and a minimal reproducer. For API proposals,
open a discussion issue first — changes to the public API require a
written rationale covering the use case, the alternative approaches
considered, and the impact on existing consumers.
Author
Zaud Rehman — @ZaudRehman · @RehmanZaud
License
Licensed under either of:
at your option.
Contribution — 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.