The Error Trait and Ecosystem

29 min read
On this page

Error design first answers two questions: what should the caller act upon, and what information does the troubleshooter need to understand the failure? Specific enums are suitable for expressing matchable categories; context explains which business step was being processed at the time. The two can coexist; there is no need to treat "recoverable" and "reportable" as mutually exclusive choices.

Error provides a common error interface

std::error::Error requires Debug and Display, and can expose another error that caused the current failure via source(). Display is for human-readable descriptions, Debug is for debugging representations; source represents structured causal relationships, not concatenating multiple strings into a single paragraph. Error

Application ContextFailed to read listening configuration when starting the service1Domain Error PortErrorPort text cannot be parsed as u162Low-level ParseIntError3Error cause chain and call stack are two different types of informationSpecific reason given by the integer parsersource chains causes; backtrace records the call stack at the time of capture and cannot be reconstructed from the cause chain.

The error cause chain may have only one layer, or it may span parsing, storage, and application layers. Backtraces require collecting stack information at a specific point in time; traversing source will not automatically derive the function call stack. Even if a stack is available, the capture location may not be where the failure originally occurred.

You can implement the cause chain completely without derive macros

First, look at the standard library version to see exactly what thiserror generates later. Field names identify the failing configuration, source preserves the original parsing error, and Display is only responsible for the explanation at the current layer.

use std::{error::Error, fmt, num::ParseIntError};
#[derive(Debug)]
struct FieldError {
    field: &'static str,
    source: ParseIntError,
}
impl fmt::Display for FieldError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Configuration field {} is not a valid u16", self.field)
    }
}
impl Error for FieldError {
    fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) }
}
fn parse(field: &'static str, text: &str) -> Result<u16, FieldError> {
    text.parse().map_err(|source| FieldError { field, source })
}
fn main() {
    let error = parse("port", "bad").unwrap_err();
    assert_eq!(error.field, "port");
    assert!(error.to_string().contains("port"));
    let source = error.source().unwrap();
    assert!(source.downcast_ref::<ParseIntError>().is_some());
    assert!(source.source().is_none());
}

source returns a borrow; the reference cannot outlive the current error object. The 'static constraint in the return type targets the erased cause type, not extending that reference into a static reference. The Error trait itself does not require all implementations to be Send, Sync, or 'static; these are often requirements imposed by inter-thread propagation and specific error containers.

If the upper-level Display has already embedded all source text, reporters printing layer by layer may repeat the same cause. Let each layer describe only the new context, and let a unified reporter expand the cause chain; if the interface chooses to display a single complete line, the overall strategy should remain consistent.

Does wrapping preserve the cause type?

Identical display text may come from very different structures: a chain preserving ParseIntError, or a String alone. Inspect the outer and inner layers to see that plain dyn Error downcasting tests the current object. Display text, causal structure and a captured stack are distinct information.

Preparing the visual
Does wrapping preserve the cause type?

source preserves a traversable cause object; to_string retains display text, not its original type.

Complete example: types for decisions, context for location

Cargo.toml:

[package]
name = "error-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2"
anyhow = "1"

src/main.rs:

use anyhow::Context;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum PortError {
    #[error("port is not a valid u16")]
    Invalid(#[from] ParseIntError),
    #[error("port zero is disabled by this application")]
    Zero,
}

fn parse_port(text: &str) -> Result<u16, PortError> {
    let port = text.parse::<u16>()?;
    if port == 0 {
        return Err(PortError::Zero);
    }
    Ok(port)
}

fn startup(text: &str) -> anyhow::Result<u16> {
    parse_port(text).context("cannot configure listening port")
}

fn main() {
    assert_eq!(startup("8080").unwrap(), 8080);
    assert!(matches!(parse_port("0"), Err(PortError::Zero)));
    assert!(matches!(parse_port("70000"), Err(PortError::Invalid(_))));

    let error = startup("bad").unwrap_err();
    assert!(matches!(error.downcast_ref::<PortError>(), Some(PortError::Invalid(_))));
    for (level, cause) in error.chain().enumerate() {
        eprintln!("{level}: {cause}");
    }
    assert_eq!(error.chain().count(), 3);
}

Here, prohibiting port 0 is an application policy, not a rule of u16 or network protocols. Underlying parsing failures are converted to Invalid via #[from]; business rejections directly construct Zero. After adding startup context at the application outer layer, PortError can still be found via downcast_ref.

thiserror generates standard trait implementations

thiserror helps generate Display, Error, and specified From implementations; the exposed errors are still their own types. #[from] simultaneously treats that field as source; since From only accepts the source error, variants cannot also require an arbitrary path or business ID field (the documentation separately allows corresponding backtrace fields). thiserror

If an error needs to carry a path, construct a variant containing path and source using map_err at the call site, preserving the original error as source, rather than storing only error.to_string(). The former preserves the cause type; the latter leaves only display text.

#[error(transparent)] is used to transparently delegate display and source behavior to the inner error, without adding a layer of business explanation out of thin air. When you need to explain "which configuration failed to read," you should explicitly model this layer of context.

The same underlying error may come from different business steps

From can only convert based on the incoming error value and cannot know whether it is reading configuration or writing a checkpoint. If multiple steps produce io::Error, add operation context at the point of occurrence to avoid conflicts from writing two identical #[from] variants.

The following example uses the thiserror dependency above; it demonstrates construction by injecting a failing operation, without actually reading or writing files.

use std::{error::Error as _, io};
use thiserror::Error;
#[derive(Debug, Error)]
#[error("Operation {operation} failed, resource {resource}")]
struct OperationError {
    operation: &'static str,
    resource: String,
    source: io::Error,
}
fn perform(resource: &str, action: impl FnOnce() -> io::Result<()>)
    -> Result<(), OperationError>
{
    action().map_err(|source| OperationError {
        operation: "load-config", resource: resource.to_owned(), source,
    })
}
fn main() {
    let error = perform("service-config", || {
        Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
    }).unwrap_err();
    assert_eq!(error.operation, "load-config");
    assert_eq!(error.source.kind(), io::ErrorKind::PermissionDenied);
    assert!(error.source().unwrap().downcast_ref::<io::Error>().is_some());
    assert!(perform("service-config", || Ok(())).is_ok());
}

Here, resource stores a resource identifier suitable for reporting, and does not need to contain the full input content. #[source] preserves the error cause, and map_err fills in context at the call site that From does not have. The number of error wrapping layers should correspond to valuable new information, rather than wrapping every layer with synonymous explanations as it passes through helper functions.

anyhow wraps errors, but does not define recovery strategies for you

anyhow::Error can accept concrete errors that satisfy Error + Send + Sync + 'static. context adds explanation, with_context allows constructing expensive context only on failure; for errors retained in the wrapper, type downcasting is still possible. anyhow

Converting all errors to strings and deciding to retry based on whether the string contains "timeout" binds business behavior to unstable text. When distinguishing between rate limiting, authentication failure, and temporary unavailability, even within the application, you should retain specific categories or stable error codes.

anyhow does not automatically catch panics, does not automatically write logs, and does not roll back side effects. It makes error propagation and reporting more convenient; recovery, retry, and transactions remain the program's own strategies.

Context can be readable or identifiable types

The following still uses the same Cargo dependencies. The context LoadingConfig is responsible for identifying the business step, the underlying ParseIntError preserves the specific failure, and both can be found via anyhow's downcast_ref.

use anyhow::Context;
use std::{fmt, num::ParseIntError};
#[derive(Debug)]
struct LoadingConfig;
impl fmt::Display for LoadingConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Failed to load listening configuration")
    }
}
fn main() {
    let error = "bad".parse::<u16>().context(LoadingConfig).unwrap_err();
    assert!(error.downcast_ref::<LoadingConfig>().is_some());
    assert!(error.downcast_ref::<ParseIntError>().is_some());
    assert_eq!(error.to_string(), "Failed to load listening configuration");
    assert_eq!(error.chain().count(), 2);
}

This uses anyhow's specific support for its context wrapper; you cannot infer that downcast on arbitrary Box<dyn Error> will automatically traverse the source chain. downcast checks the concrete type of the current object on standard trait objects; when you need to check causes, you should explicitly advance along source. anyhow Context and downcast

The 'static constraint does not mean the error must leak or live until process exit. Errors holding String can satisfy 'static and be freed quickly; errors holding temporary input &str may not fit into containers requiring 'static. When you need to retain location information across tasks, you can save owned short identifiers, ranges, or line numbers.

Division of labor: cause chain, context, backtrace

InformationQuestion AnsweredTypical Content
Error CategoryWhat can the caller do next?Permission denied, invalid configuration, temporarily unavailable
Business ContextWhich operation was being executed at the time?Loading configuration for a specific task
Source Cause ChainWhy did this layer fail?Parsing failed due to invalid number
BacktraceWhich calls were passed through at capture?Functions and locations, affected by symbol information

anyhow's stack trace support is affected by build and runtime configurations. In typical std builds, you can request error backtraces using RUST_LIB_BACKTRACE=1; RUST_BACKTRACE also involves panic backtraces. Do not treat "having a source chain" as "definitely having a complete stack."

Structured Error + Cause + ContextRetains categories available for program judgmentCaller DecisionStop, correct input, limited retryInternal DiagnosticsOperation, cause, trace IDExternal ResponseStable codes and guidanceOne internal error provides different information at different boundariesError text can be adjusted; recovery logic should not depend on string matching, and internal causes do not need to be fully exposed externally.

From error category to action, one business judgment is still missing

CategoryPossible ActionConditions Still to Confirm
Configuration Syntax ErrorPoint out the field and stop startupDo not use default values to mask explicit errors
Insufficient PermissionsCorrect permissions or identityRetrying the same request usually does not change permissions
Request TimeoutQuery results or limited retryThe original operation may have already succeeded; consider idempotency
Rate LimitingWait according to service guidelinesRetry budget, deadline, and request validity
Resource Not FoundReturn empty result or report failureWhether the resource is inherently optional

Error does not define is_retryable because "whether to retry" usually depends on operation semantics. Even if the error comes from a temporary network failure, a transfer request cannot be resent solely based on a transient flag; if the response is lost, the server may have already executed successfully. Types provide the basis for decision-making; retry protocols still need to include constraints on count, time, and idempotency.

Trade-offs at interface boundaries

Libraries providing matchable error types are usually beneficial for caller recovery, and thiserror can reduce implementation code; the application top layer uniformly carrying context is usually suitable for anyhow. They are common divisions of labor, not rules that "libraries must use a specific crate, and applications do not need to distinguish errors."

Variants of public error enums are also part of compatibility design; consider non_exhaustive as needed for extension. Error messages should be sufficient to locate the problem, but avoid recording passwords or full credentials verbatim. Usually, record once at the boundary where errors are truly handled or reported, to avoid outputting the same failure repeatedly as it propagates through each layer.

If a public enum exposes third-party parser errors directly as variant fields, callers may start matching that specific type, and upgrading dependencies will affect the API. You can provide stable category accessors as needed, while keeping implementation details in private representations; the cost is that callers have less information they can directly depend on. non_exhaustive only affects external exhaustive matching; it does not automatically stabilize field types, error codes, or recovery semantics.

External APIs can return stable code, short message, and correlation identifiers, while internal logs retain more detailed causes; do not treat Debug or complete source chains as long-term compatible response formats. Tests can assert variants, key fields, source types, and necessary context, avoiding turning third-party full English error reports into contracts that must remain exactly the same forever.

First master the propagation semantics of Result and Option, then decide on error types and reporting methods, to avoid misunderstanding ? as an automatic recovery or logging mechanism.