Skip to content
← All Rust articles

Rust explainer

Error enums vs Box<dyn Error>

About 6 min to read. Allow extra time to work through examples.

Choose error types around caller recovery, API evolution and thread boundaries, with runnable examples.

This article uses Rust. You can make it your practice mode to browse related content across the site.

An error type helps callers decide what to do when an operation fails. An error enum gives them named categories to match. Box<dyn Error> lets a function return different concrete error types through one interface. Neither choice determines whether an error can be reported well or recovered from. Start with the information your callers need.

An enum for distinct responses

Suppose an application uses a default when a setting is absent, but reports invalid input. Named variants make that policy explicit:

use std::{error::Error, fmt, num::ParseIntError};

#[derive(Debug)]
enum ConfigError {
    Missing,
    Invalid(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Missing => write!(f, "worker count is missing"),
            Self::Invalid(_) => write!(f, "worker count is not a valid u32"),
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Missing => None,
            Self::Invalid(error) => Some(error),
        }
    }
}

fn workers(raw: Option<&str>) -> Result<u32, ConfigError> {
    let raw = raw.ok_or(ConfigError::Missing)?;
    raw.parse().map_err(ConfigError::Invalid)
}

fn configured_workers(raw: Option<&str>) -> Result<u32, ConfigError> {
    match workers(raw) {
        Err(ConfigError::Missing) => Ok(4),
        result => result,
    }
}

fn main() {
    assert_eq!(configured_workers(None).unwrap(), 4);
    assert_eq!(configured_workers(Some("8")).unwrap(), 8);
    let error = configured_workers(Some("many")).unwrap_err();
    assert!(matches!(error, ConfigError::Invalid(_)));
    assert!(error.source().unwrap().is::<ParseIntError>());
}

The parser keeps the underlying cause through source(). The application chooses the default; another caller could treat a missing setting as fatal. This parser checks only u32 syntax and range, so zero is accepted too.

Display supplies a readable message. It does not automatically include the source chain. A reporting layer can walk source() when it needs the details. A From implementation can make conversion with ? convenient, but is not required: this example uses map_err to select the variant.

The enum documents categories of returned errors. It does not prove that the function cannot panic or that every possible failure has its own variant.

A box for several concrete error types

A boxed trait object retains the concrete error value behind the interface. Callers can display it, inspect its source chain, or downcast to a known type:

use std::{error::Error, num::ParseIntError};

fn workers(raw: Option<&str>) -> Result<u32, Box<dyn Error>> {
    let raw = raw.ok_or("worker count is missing")?;
    Ok(raw.parse()?)
}

fn main() {
    assert_eq!(workers(Some("8")).unwrap(), 8);
    assert_eq!(workers(None).unwrap_err().to_string(), "worker count is missing");
    let error = workers(Some("many")).unwrap_err();
    assert!(error.downcast_ref::<ParseIntError>().is_some());
}

Here ? uses available conversions into the return error type. The standard library supports boxing concrete errors implementing Error + 'static, and also converts String and &str messages into boxed errors. It does not accept arbitrary values or remove lifetime and thread-safety requirements.

Downcasting is useful when the API documents which concrete types callers can expect. Unlike matching an enum, it cannot check an exhaustive list of possible error types. A plain message also lacks a named recovery category. If callers need to distinguish a missing setting from invalid input, expose that distinction through a documented type or method instead of asking them to compare prose.

Decide which thread bounds you need

Box<dyn Error> does not promise Send or Sync, even when a particular value inside it has those traits. A common error alias for work that crosses thread boundaries is:

use std::{error::Error, thread};

type Report = Box<dyn Error + Send + Sync + 'static>;

fn main() {
    let task = thread::spawn(|| -> Result<u32, Report> {
        Ok("8".parse()?)
    });
    assert_eq!(task.join().unwrap().unwrap(), 8);
}

thread::spawn requires its closure and return value to be Send + 'static. Send permits moving a value between threads; Sync permits sharing a reference between threads. The alias includes both for APIs that need both, although moving an owned error alone does not require Sync.

These bounds restrict the errors you can store. An error containing Rc<String> cannot satisfy them. A local task may not need either bound. An enum's thread safety also depends on its fields; choosing an enum does not make it Send.

'static means the type has no borrow tied to a shorter lifetime. Owned values and values containing &'static str can satisfy it. It does not mean the error must remain alive until the program exits.

Plan for changes to the public API

Adding a variant to a public exhaustive enum is normally a breaking change: downstream code may match every existing variant without a fallback. An enum marked #[non_exhaustive] requires downstream variant matches to allow unknown variants, so future additions can be compatible. Adding that attribute to an already-published exhaustive enum is itself a breaking change.

Public variant fields also expose their types. If a field contains a dependency's error type, changing that type can break callers. You can instead expose stable categories through a struct with private fields and keep the detailed cause behind source(). The standard library's std::io::Error illustrates this approach with its kind() method; ErrorKind is itself non-exhaustive.

A boxed return type avoids listing every concrete error in the signature. It does not make all behavior changes compatible: callers may rely on documented failure conditions, bounds or downcast targets. Document those contracts too.

Measure the workload, including successful calls

An enum stores its selected payload inline, with layout affected by alignment, padding and the compiler's representation choices. A large error can increase the size of Result<T, E>, depending on T and E. Boxing a large payload can reduce inline size, at the cost of indirection and potentially allocation.

Neither representation has one fixed cost per error. Enum payloads may allocate strings or other data. Boxing a non-zero-sized error ordinarily needs heap storage, while zero-sized values do not need an allocation; optimization can also affect observed allocations. Formatting and collecting context add their own costs.

Even successful calls can be affected by result layout and generated code. Benchmark representative inputs, success and failure rates, and reporting work before choosing a representation for speed. Record the build settings and hardware rather than treating one timing ratio as a general rule.

Frequent failures can still deserve Result: a parser may need to count, report or categorize rejected records. Use Option or filter failures out only when discarding their explanation is part of the intended API.

Choose at each boundary

Caller needs Useful design
Different actions for named failure categories Enum or a stable category method
Reporting varied failures without enumerating each type Boxed error with the required bounds
Stable categories while hiding implementation details Public wrapper with private fields and a source chain
New public categories without exhaustive downstream matches Non-exhaustive category enum with a documented fallback policy

You can combine these designs. Keep typed categories where recovery happens, then convert to a boxed error at a reporting boundary. Choose the categories, thread bounds and compatibility promises deliberately; library versus application is only a starting point.

Further reading: the Error trait, Cargo's API compatibility guidance and thread::spawn requirements.

step 17 of 24

Errors Are Values

Up next Boxed errors and downcasting