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 downstreammatchneeds a_arm — which some people find defeats the point. -
std itself uses a third design.
std::io::Erroris neither: it is a struct with a coarseErrorKindenum 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?