Skip to content
← All articles

Error enums vs Box<dyn Error>

The design decision that is semver-visible and expensive to get wrong — with measured numbers that correct the folklore in both directions.

You now know how to build both. This is how to choose, and it is the decision that separates someone who can implement Error from someone who can design an error API.

It matters more than most style decisions because it is semver-visible. An error enum is part of your public API: adding a variant is a breaking change for anyone who matched exhaustively, and removing one certainly is. Getting it wrong costs a major version.

The two shapes

// (a) an enum
pub enum ConfigError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    MissingKey(String),
}

// (b) type erasure
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

What the enum buys

Callers can react. This is the whole point:

match load_config() {
    Err(ConfigError::MissingKey(k)) => install_default(&k),
    Err(ConfigError::Io(e)) if e.kind() == ErrorKind::NotFound => create_config(),
    Err(e) => return Err(e),
    Ok(c) => c,
}

Exhaustiveness. Add a variant and every match in every downstream crate that did not use a wildcard stops compiling. That is a feature — it is how you tell your users that a new failure mode exists.

It is documentation. The variant list is the list of things that can go wrong, checked by the compiler against reality.

What the enum costs

Your public API now names every dependency’s error type. ConfigError::Io exposes std::io::Error; if a variant wraps serde_json::Error, then serde_json is in your public interface, and bumping its major version bumps yours. This is the cost people underestimate.

Boilerplate per source type — a variant, a From, a Display arm, a source() arm. (thiserror exists to erase exactly this, which is why the next item exists.)

Size. An enum is as large as its biggest variant. Wrap something chunky and every Result<T, ConfigError> in the program grows, including the millions that succeed. clippy::result_large_err and clippy::large_enum_variant are the lints; Box the fat variant is the fix.

What Box<dyn Error> buys

? accepts everything, via the blanket impl:

impl<E: Error + 'static> From<E> for Box<dyn Error>

No variant, no From, no Display arm. Add a new fallible call and the code still compiles.

It goes further than you might expect — &str and String have impls too, so this is legal:

fn f(ok: bool) -> Result<i32, Box<dyn Error>> {
    if !ok { Err("plain str")? }
    Ok(1)
}

Verified: that compiles and produces an error whose Display is plain str. Convenient. Also a stringly-typed contract in disguise, if you build a habit of it.

What Box<dyn Error> costs

Callers can only downcast. e.downcast_ref::<ParseIntError>() is a runtime type test that returns Option, there is no exhaustiveness, and nothing tells a caller which types are worth testing for. It is a stringly-typed contract with extra steps: your users end up matching on message text, or giving up and logging.

Not Send. Box<dyn Error> cannot cross a thread::spawn boundary, and learners who meet that inside a threading exercise usually blame threads. The portable form is:

Box<dyn Error + Send + Sync + 'static>

which is what anyhow::Error uses internally, and what you should reach for in anything that might touch a thread pool or an async runtime.

💡If Box<dyn Error + Send + Sync> is strictly more useful, why is the plain Box<dyn Error> the one everybody writes first? click to reveal

Because it is the one in the Book’s examples and the shorter one to type, and because in a single-threaded main it works fine.

The bounds are not free, though — they are a real constraint on what you can put in the box. Send means the value can move between threads; Sync means &T can. An error holding an Rc<T> is neither, so it will not fit. In practice virtually every error type satisfies all three, which is exactly why the API guidelines say error types should be Send + Sync + 'static and why you should add the bounds by default rather than discover you need them at the point where adding them breaks four call sites.

'static here bounds the erased type, not a reference: “this contains no borrowed data”. It is also what downcast_ref requires, since a type that borrows has no stable identity.

The performance question, measured

Folklore runs in both directions here — “boxing allocates, avoid it” and “the error path never matters” — and both are half right. Measured over 200 000 parses on this toolchain:

Happy path (no errors at all):

error type time
enum 0.531 ms
Box<dyn Error> 0.356 ms
String 0.364 ms

Read that table as noise. The enum came out slowest here and there is no mechanism by which it should be — nothing allocates, nothing is constructed, the error type is a size annotation on a value that never exists. The useful conclusion is the negative one: when no errors occur, the error type does not matter.

At a 10% error rate:

error type time relative
enum 0.333 ms 1.0×
Box<dyn Error> 0.486 ms 1.5×
String via format! 0.708 ms 2.1×

Here the ordering is real and mechanical: constructing an enum variant is a tag write, boxing is a heap allocation, and format! is an allocation plus formatting. The cost is confined to the error path and proportional to the error rate.

💡A parser is called once per line on a 10-million-line log file, and about 30% of lines are malformed and expected to be skipped. Which error type, and why? click to reveal

The enum — and notice that this is a performance argument for once, not a design one.

At a 30% error rate you are constructing three million errors. The 1.5×/2.1× multipliers above stop being rounding errors and start being seconds. An enum variant costs a tag write; a box costs an allocation and a free.

But there is a better answer available, which is to notice that a skipped line is not an error at all. If 30% of lines are expected to be malformed and the program’s response is “ignore it”, the honest signature is Option<Record>, or an iterator that yields only the good ones. You are paying to construct error values that nobody reads.

That is the general shape of the performance question here: by the time the error type’s cost is measurable, the more interesting question is usually whether those failures should be errors at all. Errors are for the exceptional path; if yours is 30% of the input, it is not exceptional.

The heuristic, stated as a heuristic

Enums for libraries. Boxed for applications.

The reasoning, not the slogan: a library does not know what its callers need to do about a failure, so it should preserve the information and let them decide. An application usually knows exactly what it does about a failure — log it, show it, exit — and the concrete type is never inspected, so preserving it is work with no consumer.

Four refinements worth having:

  • The real test is not “library or binary”, it is “must callers match on this?” A binary with a well-factored internal API can want enums; a library whose errors are always fatal can reasonably box. Do not let the slogan make the decision for you.
  • You can do both. Enums at the layer where reactions happen, boxed above it. Nothing forces one choice per crate.
  • #[non_exhaustive] softens the semver problem. It stops downstream crates from matching exhaustively, so adding a variant is no longer breaking. The price is that every downstream match needs a _ arm — which some people find defeats the point.
  • std itself uses a third design. std::io::Error is neither: it is a struct with a coarse ErrorKind enum for matching and an optional boxed payload for detail. You get exhaustive-ish matching on the kind without the concrete inner type being part of the API. For a widely-used library boundary it is often the best of the three, and it is worth reading the source once.
💡You are designing a config-loading crate. Failures: file not found, invalid TOML, missing required key, value has the wrong type. Which design? click to reveal

An enum, and the reason is in the list itself — those four failures call for four different reactions:

  • file not found → fall back to defaults, or create one
  • invalid TOML → show the user the syntax error and stop
  • missing key → maybe fall back to a default for that key
  • wrong type → definitely stop, this is a mistake in their file

A caller that cannot distinguish them cannot implement any of that. With a boxed error they would be reduced to downcasting (fragile, and they would have to guess your types) or matching on message strings (worse).

Two design details for this specific enum:

  • Wrap the TOML parse error rather than stringifying it, and expose it through source(), so a caller who wants line and column can downcast to it.
  • Mark the enum #[non_exhaustive] — you will add variants (environment variable overrides, an include directive, a permissions error), and you would rather that not be a major version each time.

And note what the boxed alternative would actually cost here. It is not performance; it is that your users would write a match on e.to_string() inside six months.

Summary

enum Box<dyn Error>
caller can match yes, exhaustively only by downcasting
adding a failure mode breaking (unless #[non_exhaustive]) free
boilerplate per source type none
public API surface names your dependencies hides them
Send + Sync free must be requested
cost per error a tag write an allocation
best for libraries whose callers react applications that log and exit

Neither is a default. The question that decides it is always the same one: does anybody need to match on this?