Why Use Structured Errors in Rust Applications?
Table of Contents
TL;DR I prefer thiserror
enums over anyhow
, even for application code that
simply propagates errors. Custom error types require additional effort, but make
the code easier to reason about and maintain down the road.
“Using thiserror
for libraries and anyhow
for applications”
In 2025, this is the conventional error handling practice in the Rust community. Let’s quickly recap the main ideas:
- Libraries should expose error types that provide as much information as possible. This allows the callers to detect and handle specific error conditions. A specific error code is more useful than “Oops, something went wrong”. Structured 1 error types facilitate statically-checked pattern matching, which is more reliable than runtime reflection, downcasting, or regex-matching an error message. They also provide nice autogenerated docs, which is very important for libraries.
- Unfortunately, propagating structured errors in Rust is associated with the
boilerplate of manually writing out wrapper error types, their
impl
s and conversions. 2 thiserror
removes the boilerplate around implementingDisplay
,Error
andFrom
for your error types. Using it is a no-brainer for libraries that can “afford” proc macro dependencies. Although, you still need to define and annotate the wrapper types themselves, which there are usually plenty.- End applications often don’t care about the specific cause of an error and
simply propagate errors up the stack until they reach some kind of a
“catch-all” point. For this use case, even
thiserror
-assisted error types are often considered too cumbersome and not worth it. - Instead, application developers commonly use
anyhow
. It provides a “universal” dynamic error type, along with helper methods that make dealing with it even easier than returning a string. 3
If you google “error handling in Rust”, the typical introductory posts are going
to start with the basics of panic
, Option
and Result
, then explain these
two libraries, give this rule of thumb to choose bethween them, show some code,
and stop right here.
Pattern matching isn’t the only reason to use structured errors
At work, my team has written a ~50k LoC web server in Rust. We’ve been
maintaining it full-time for over a year. It mostly follows the same pattern of
“just propagate the error” and almost never pattern-matches specific error
conditions. The main benefit of structured errors is not applicable. It sounds
like we should be using something like anyhow
instead of spending time on
maintaining our own error types. But I found many other benefits of doing so:
- Custom error types allow you to see all potential failure modes of a function at a glance, without (recursively) inspecting its implementation or maintaining fragile hand-written docs that can’t be fully trusted anyway. In a code review, you can easily notice when some error variant doesn’t make sense and should be handled locally, or comes from an action that shouldn’t be performed at all. Interfaces become more descriptive.
- When the error type is narrow and descriptive enough, you can understand the
function just by looking at its signature, without even going to the error
definition:
fn foo(..) -> Result<.., TheOnlyPossibleFailure>
- You don’t repeat yourself when the same error is thrown from multiple places. Each error message is specified once when you define the type 4. This results in a more consistent user experience, as well as better developer experience. Now your domain errors have IDE integration, with operations like “find references” and so on.
- The logic is less littered with error messages or adding context.
return Err(MyType)
,?
ormap_err
are usually more compact than full error text formatting. There may be more code overall, but the error-related code is moved “out of the way” to the type definition. - Although
anyhow
provides convenience methods for adding context to errors, in practice it’s easy to forget, and?
works happily without context. I had to debug such cases multiple times. In contrast,thiserror
derive forces you to think about this, because you have to explicitly put something in the#[error(...)]
annotation. Even if you roll with#[error(transparent)]
(no context), that’s now an explicit choice that can be questioned in a code review, etc. - Custom error types can be enriched with additional data and
functionality5:
- You can slap on
#[derive(Serialize)]
to log/return nested JSON data along with an error. - You can implement
std::process::Termination
to give your error an associated ExitCode . - You can implement
axum::IntoResponse
to give your error an associated HTTP status code . - You can implement something like this to have localized error messages:
trait Localize { fn localize(&self, language: Language) -> String; }
- You can slap on
The tradeoffs
Custom errors have their drawbacks:
- It’s more code and more types 6.
- To combat the boilerplate, you usually introduce a third-party dependency like
thiserror
. 7 - You need to put thought into structuring the code, because otherwise no one will find and reuse your existing error types.
- You need to come up with names for error types and enum variants. These names
may not be as clear as full error messages. So, in order to understand the
code, you may end up jumping to the message anyway. I
requested
a feature
in
rust-analyzer
that would allow previewing#[error(..)]
attributes without jumping away from the code that I’m working on. - You also need to maintain these names as the codebase evolves! In my team, we often forgot to update the type/variant name when updating an error message.
- If an error enum is public in its crate, the compiler doesn’t warn about unused variants. This means that sometimes unused variants may accumulate and you need to manually trim them. 8
If your application is performance-sensitive, there are also performance considerations that don’t present a clear winner:
- Should your errors be cheap to create? Are your errors frequently discarded
without ever being displayed? Are your errors displayed multiple times?
Currently, the contents of
anyhow::Error
are heap-allocated, the arguments toanyhow!
are eagerly formatted , and a backtrace may be captured depending on environment variables 9. Typical custom errors avoid allocations and are lazily formatted. - Should your errors be small and cheap to propagate?
Currently,
anyhow::Error
is always a single pointer, while custom errors range from zero-sized unit structs to large flat collections of values that are waiting to be formatted. You may need to box and/or preformat your custom errors.
Assessing the tradeoff is up to you. Structured errors are worth it in my application.
To be continued
To present the full picture, I also wanted to cover:
- How to actually manage your custom error types to maximize the benefits and minimize the maintenance cost.
- A concrete example of error types helping me reason about the requirements in my application.
But this saga takes too long, so these topics go to their own separate posts and this post ends here. 🚧
I have to be ✨ agile ✨ in order to actually publish anything.
Related reading
Check out my first post about error handling:
I’ve also found these good posts about structured errors in Go and Haskell, but they’re quite detailed and the actual approach in the code is quite different from mine:
I’ll discuss mine in the next post in the series.
Discuss
“Structured errors” = non-opaque, non-string error types. Types that have a “shape”. ↩︎
This is caused by having a nominal type system in general and lacking anonymous unions in particular. ↩︎
Returning an error string is a common pattern among beginners, because it’s very easy to understand and use.
anyhow
retains the same advantages while also supporting richer features like backtraces, error chaining and automatic conversions from library error types. ↩︎Actually, error messages are specified in the
Display
impl, rather than the type itself. But when usingthiserror
, the error messages appear as annotations on the type. ↩︎While most of this post comes from my real experience, the examples in this list are theoretical. I’ve never actually implemented these features. Please call me out if something’s wrong with this section. Also, send concrete examples, if you know any! ↩︎
IMO, it’s totally worth it, because these types replace hand-written docs that list the possible errors. Type-checked docs are the best! ↩︎
For dynamic errors, you usually introduce an
anyhow
-style dependency too. But if you want to avoid dependencies, then living with rawBox<dyn Error>
is probably easier than hand-writing impls for all your custom types. ↩︎Just like with the point about “jumping to the error message”, one can argue that this is just a limitation of our current tooling, rather than a fundamental flaw of custom enums. ↩︎
Currently, capturing backtraces is expensive. See backtrace-related issues in the
anyhow
repo. ↩︎
Comments