thiserror and anyhow were great in 2021. It's 2025 now.
Modern production systems need:
- ✅ Structured observability (not just error messages)
- ✅ Transport-aware errors (HTTP/gRPC ready)
- ✅ Built-in redaction (GDPR/compliance by default)
- ✅ Typed metadata (not
HashMap<String, String>)
masterror provides all of this while maintaining API compatibility.
// thiserror in 2025 - same as 2021
#[derive(Error, Debug)]
#[error("Database error: {0}")]
struct DbError(#[from] sqlx::Error);
// ❌ No structured metadata
// ❌ No HTTP status mapping
// ❌ No redaction policy
// ❌ No telemetry integration
// ❌ You implement this yourself, every timeThe reality: Every production service adds the same boilerplate:
- Manual HTTP status code mapping
- Custom metadata extraction
- Homegrown redaction logic
- Manual tracing integration
This is 2025. Stop reinventing the wheel.
// anyhow in production - convenient but limited
fn process() -> anyhow::Result<()> {
do_thing().context("operation failed")?;
Ok(())
}
// ❌ No type safety for errors
// ❌ Lost error information at boundaries
// ❌ No structured fields
// ❌ No automatic transport mapping
// ❌ String-based context onlyThe reality: anyhow is perfect for CLIs and scripts.
For production APIs serving millions of requests? You need more.
// Drop-in replacement for thiserror
use masterror::Error;
#[derive(Debug, Error)]
#[error("Database error: {0}")]
struct DbError(#[from] sqlx::Error);
// ✅ Same API as thiserror
// ✅ Plus: automatic AppError conversion
// ✅ Plus: HTTP/gRPC mappings availableMigration effort: change one import line.
// All the anyhow convenience, with types
use masterror::prelude::*;
fn process() -> AppResult<()> {
ensure!(condition, AppError::bad_request("invalid input"));
// Simple context (anyhow-style)
database_call().context("db operation failed")?;
// Or structured context with metadata
database_call()
.ctx(|| Context::new(AppErrorKind::Database)
.with(field::str("table", "users"))
.with(field::u64("attempt", retry_count))
)?;
fail!(AppError::internal("unrecoverable"));
}
// ✅ Same ergonomics as anyhow (.context(), .chain(), .downcast_ref())
// ✅ Plus: typed errors
// ✅ Plus: structured metadata
// ✅ Plus: automatic tracing// thiserror/anyhow: lose context
let err = anyhow!("DB error: user={}, attempt={}", user_id, attempt);
// ❌ String formatting
// ❌ Lost type information
// ❌ Can't query/filter in observability tools
// masterror: structured context
let err = AppError::database("query failed")
.with(field::str("user_id", user_id))
.with(field::u64("attempt", attempt))
.with(field::duration("elapsed", elapsed));
// ✅ Typed fields
// ✅ Queryable in logs/metrics
// ✅ Automatic JSON serialization// thiserror/anyhow: manual mapping
impl IntoResponse for MyError {
fn into_response(self) -> Response {
let status = match self {
MyError::NotFound => StatusCode::NOT_FOUND,
MyError::Unauthorized => StatusCode::UNAUTHORIZED,
// ... repeat for every error type
};
// ... manual JSON construction
}
}
// masterror: automatic
#[derive(Debug, Masterror)]
#[masterror(
category = AppErrorKind::NotFound,
code = AppCode::NotFound,
map.grpc = 5 // NOT_FOUND
)]
struct UserNotFound { id: String }
// ✅ HTTP 404 automatic
// ✅ gRPC NOT_FOUND automatic
// ✅ RFC7807 Problem JSON automatic// thiserror/anyhow: manual redaction
let err = format!("Auth failed: {}", token); // ❌ LEAKED SECRET
// masterror: policy-based
AppError::unauthorized("auth failed")
.with(field::str("token", token))
.redact_field("token", FieldRedaction::Hash)
.redactable();
// ✅ Token hashed in logs
// ✅ Message redacted in client response
// ✅ GDPR/compliance ready// thiserror/anyhow: manual instrumentation
tracing::error!(
error = %err,
user_id = user_id,
attempt = attempt,
"database error"
);
metrics::counter!("errors_total").increment(1);
// masterror: automatic
let err = AppError::database("query failed")
.with(field::str("user_id", user_id))
.with(field::u64("attempt", attempt));
// ✅ Automatic tracing event
// ✅ Automatic metrics increment
// ✅ Automatic backtrace capture
// ✅ Zero boilerplate// anyhow: type-safe error inspection
if let Some(io_err) = err.downcast_ref::<io::Error>() {
match io_err.kind() {
io::ErrorKind::NotFound => /* handle */,
_ => /* other */
}
}
// masterror: same API, works with AppError
use masterror::ResultExt;
match database_op().context("db failed") {
Err(err) => {
if let Some(io_err) = err.downcast_ref::<io::Error>() {
// ✅ Type-safe downcasting
// ✅ Inspect wrapped error sources
// ✅ Full anyhow API compatibility
}
}
Ok(val) => val
}Step 1: Update imports
// Before
use thiserror::Error;
// After
use masterror::Error;Step 2: Enhance (optional, add when needed)
#[derive(Debug, Error)]
#[error("DB error")]
#[app_error(
kind = AppErrorKind::Database,
code = AppCode::Database
)]
struct DbError {
#[from]
source: sqlx::Error
}Done. Your code still compiles. Add features incrementally.
Step 1: Replace Result type
// Before
use anyhow::{Result, Context};
// After
use masterror::{AppResult, ResultExt};Step 2: Update error construction
// Before
bail!("invalid input");
// After
fail!(AppError::bad_request("invalid input"));Step 3: Keep using .context() (it just works!)
// Before (anyhow)
.context("db error")?
// After (masterror) - identical API
.context("db error")?
// Or use structured context for better observability
.ctx(|| Context::new(AppErrorKind::Database)
.with(field::str("table", "users"))
)?Step 4: Error introspection works the same
// anyhow API still works
if let Some(io_err) = err.downcast_ref::<io::Error>() {
// handle specific error type
}Result: Type-safe, structured, observable errors with zero API friction.
#[derive(Error, Debug)]
#[error("User {user_id} not found")]
struct UserNotFound { user_id: String }
// Manual HTTP mapping
impl IntoResponse for UserNotFound {
fn into_response(self) -> Response {
(StatusCode::NOT_FOUND, Json(json!({
"error": "not_found",
"message": self.to_string(),
}))).into_response()
}
}
// Manual tracing
tracing::error!(user_id = %self.user_id, "user not found");
// Manual metrics
metrics::counter!("errors_total", "type" => "not_found").increment(1);Lines of code: ~20 per error type
#[derive(Debug, Masterror)]
#[error("User {user_id} not found")]
#[masterror(
code = AppCode::NotFound,
category = AppErrorKind::NotFound,
message,
telemetry(Some(field::str("user_id", user_id.clone())))
)]
struct UserNotFound { user_id: String }Lines of code: 8
Savings: 60% less boilerplate, 100% more features
masterror is competitive with thiserror/anyhow:
| Operation | thiserror | anyhow | masterror |
|---|---|---|---|
| Error creation | 34ns | 23ns | 30ns |
| Root cause | N/A | 139ns | 141ns |
| Display | 133ns | 74ns | 79ns |
Overhead is minimal (~5-20ns) for significantly more functionality.
Binary size: 944KB (vs thiserror 32KB, anyhow 566KB)
- Worth it: Includes HTTP/gRPC/telemetry/redaction that you'd implement anyway
- ✅ Map to transports (HTTP/gRPC) automatically
- ✅ Include structured metadata for observability
- ✅ Respect privacy with built-in redaction
- ✅ Integrate with telemetry out of the box
- ✅ Maintain type safety across boundaries
thiserror gives you #5. You build #1-4 yourself.
anyhow gives you convenience. You lose #5 and build #1-4 yourself.
masterror gives you everything.
- Week 1: Add masterror to dependencies
- Week 2: Migrate one service/module
- Week 3: Evaluate metrics (less code, better observability)
- Week 4+: Roll out to remaining services
ROI: Immediate reduction in boilerplate, improved observability, compliance-ready errors.
- 📚 Full Documentation
- 📊 Benchmarks
- 🔧 Examples - See working code for:
- Basic Usage - Core error handling patterns
- thiserror Compatibility - Drop-in replacement
- Structured Metadata - Typed fields vs strings
- Redaction - GDPR-compliant privacy controls
- 💬 GitHub Issues
Stop building the same error infrastructure over and over.
Start shipping features instead.
Switch to masterror.