Error Handling in Rust with thiserror
Rust handles errors differently from many other languages: instead of
exceptions, failures are represented explicitly in the type system using
Result<T, E>. This encourages developers to model errors as real data, making
programs easier to reason about and maintain. In practice, this often means
defining custom error types that describe what can go wrong in a particular
domain. The thiserror crate simplifies this process by removing the
boilerplate normally required to implement Rust's error traits, allowing
developers to focus on designing clear and expressive error models.
In this post, we walk through the fundamentals of structured error handling in
Rust, explore how thiserror works, and show how to design practical error
types for real applications.
1. The Problem with String Errors
Error handling is a central part of Rust’s design. Instead of exceptions, Rust uses the type system to make failures explicit and visible in function signatures.
Most fallible operations in Rust return the type:
Result<T, E>
This type represents a computation that can either succeed or fail.
Ok(T)— the operation succeeded and produced a value of typeTErr(E)— the operation failed with an error value of typeE
For example:
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
s.parse()
}
Here the function signature tells us something very important: parsing may
fail, and if it does, the failure will be represented by a ParseIntError.
This design is very different from languages that rely on exceptions. In those languages, a function may throw errors that are not visible in the function signature, forcing developers to rely on documentation or runtime behavior to understand what might go wrong.
Rust instead pushes error handling into the type system. When you call a
function that returns a Result, the compiler requires you to deal with the
possibility of failure.
This makes error handling explicit, predictable, and composable.
Library Errors vs Application Errors
Rust also tends to distinguish between two kinds of errors:
Library errors
These represent failures that library users may want to handle programmatically. For example:
- invalid input
- missing files
- parsing failures
- network errors
Because callers may want to react differently to these cases, library errors should be structured and typed.
Application errors
These usually occur at the boundary of a program, for example in a CLI application. At this level, errors are often just reported to the user and the program exits.
Libraries therefore need well-designed error types, while applications can often aggregate and display them.
The Problem with String Errors
A common beginner pattern is to represent errors as strings:
fn read_config() -> Result<String, String> {
Err("config missing".into())
}
This compiles, but it creates several problems.
First, the error is not typed. A String tells us nothing about what kind
of failure occurred. Was the file missing? Was the file unreadable? Was the
format invalid? All of these errors would look the same.
Second, string errors are impossible to match on in a reliable way. If another part of the program wants to handle a specific failure, it would have to compare strings:
if err == "config missing" {
// fragile and brittle
}
This approach is brittle and error-prone. A small change in wording can break the logic.
Third, strings lose context and structure. Errors often carry useful information:
- which file failed to open
- which port number was invalid
- which field failed to parse
Strings flatten all of this information into text, making it difficult to extract or propagate structured data.
The Rust Approach
Instead of strings, Rust programs typically define custom error types.
These are usually implemented as enums that describe the possible failure
modes of an operation.
For example:
enum ConfigError {
MissingFile,
InvalidPort(u16),
}
Each variant represents a specific failure, and variants can carry additional data when necessary.
This approach gives us several advantages:
- errors are typed
- callers can match on specific failures
- errors can carry structured data
2. Modeling Errors with Enums
Before introducing thiserror, it’s important to understand how error types
are designed in plain Rust. The key idea is simple: errors are data.
Instead of representing failures as strings, we model them explicitly using
types.
In Rust, the most common way to represent an error type is with an enum.
For example, imagine we are writing a function that reads a configuration file. Several things could go wrong:
- the file might not exist
- the configuration might contain an invalid port number
We can represent these failures with an enum:
#[derive(Debug)]
pub enum ConfigError {
MissingFile,
InvalidPort(u16),
}
Each variant of the enum represents a specific failure mode.
MissingFilerepresents a configuration file that could not be found.InvalidPort(u16)represents a port number that failed validation, and it carries the invalid value.
This approach has an important advantage: the error type precisely describes the possible failures of the operation.
Instead of returning a vague String, our function can return:
fn read_config() -> Result<String, ConfigError>
Now the function signature communicates something meaningful: if the operation
fails, it will fail with a ConfigError.
Enums Are Perfect for Error Modeling
Enums work extremely well for error types because failures usually fall into distinct categories.
Each variant represents a different kind of problem. Variants can also carry data when additional context is useful.
For example:
- a missing file does not need extra information
- an invalid port benefits from including the problematic value
This makes error handling both structured and expressive.
Matching on Errors
Because the error is a real type, callers can handle different failures explicitly using pattern matching:
match err {
ConfigError::MissingFile => {
println!("Configuration file not found");
}
ConfigError::InvalidPort(port) => {
println!("Invalid port number: {}", port);
}
}
This is far more robust than matching on strings. The compiler ensures that all variants are handled correctly, and refactoring the error type will automatically surface any places that need updating.
Domain Errors
Errors like ConfigError are often called domain errors.
A domain error describes something that went wrong within the problem domain of the program. In this case, the domain is configuration loading.
Examples of domain errors include:
- invalid user input
- malformed data
- missing resources
- violated constraints
By modeling these failures explicitly, we make the program easier to reason about and easier to maintain.
Modeling Failures Explicitly
This approach reflects a broader Rust philosophy: make invalid states unrepresentable and make failures explicit.
Instead of hiding errors behind strings or exceptions, Rust encourages developers to model them as part of the type system.
The downside is that implementing fully featured error types can involve some
boilerplate, especially when implementing traits like Display and
std::error::Error.
3. Manual Error Implementations
Defining an error enum is only the first step. In Rust, error types are expected to implement a few standard traits so they integrate well with the rest of the ecosystem.
At minimum, most error types implement:
DebugDisplay
And often they also implement:
std::error::Error
The Debug Trait
The Debug trait is primarily used for developer-facing output, such as
logging or debugging messages. We already derived it earlier:
#[derive(Debug)]
pub enum ConfigError {
MissingFile,
InvalidPort(u16),
}
This allows the error to be printed with {:?}.
The Display Trait
The Display trait is used for user-facing error messages. This is what
gets printed when an error is displayed in a CLI or returned to a user.
Unlike Debug, Display usually needs to be implemented manually.
Here is what that implementation looks like for our error type:
use std::fmt;
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::MissingFile => write!(f, "configuration file missing"),
ConfigError::InvalidPort(p) => write!(f, "invalid port: {}", p),
}
}
}
The implementation pattern is straightforward:
- Match on the error variant
- Write an appropriate error message to the formatter
This gives us clean user-facing messages such as:
configuration file missing
or
invalid port: 808080The std::error::Error Trait
Many Rust libraries also expect errors to implement the std::error::Error
trait. This trait allows errors to participate in error chains, where one
error wraps another.
For simple error types, the implementation is often trivial:
impl std::error::Error for ConfigError {}
But when errors wrap other errors, additional methods like source() may be required.
The Boilerplate Problem
None of this code is particularly difficult, but it is repetitive.
For every error type you define, you typically need to write:
- the
enumdefinition - a
Displayimplementation - sometimes an
Errorimplementation - sometimes conversions from other error types
Most of this code follows the same pattern:
- match on the enum
- format a message
- forward errors when necessary
As projects grow, this boilerplate can become tedious and distracting from the actual logic of the program.
Fortunately, this is exactly the kind of repetitive code that Rust’s derive macros can eliminate.
The thiserror crate exists specifically to remove this boilerplate while
keeping error types fully idiomatic and zero-cost.
4. Enter thiserror
As we saw in the previous section, implementing Rust error types manually
involves a fair amount of repetitive code. For every error enum, we typically
write:
- the
enumdefinition - a
Displayimplementation - sometimes an implementation of
std::error::Error - possibly conversions from other error types
This pattern is so common that the Rust ecosystem has a widely used crate to
eliminate the boilerplate: thiserror.
thiserror is a small crate that provides a derive macro for error types.
Instead of writing manual trait implementations, you annotate your error enum
and let the macro generate the code.
Some key characteristics of thiserror:
- Lightweight: it only provides derive macros and adds no runtime dependencies.
- Zero runtime cost: it generates the same code you would write manually.
- Ideal for library error types: it helps you create clean, structured, idiomatic error definitions.
Because it only generates standard Rust implementations, using thiserror does
not change the behavior of your program. It simply removes the boilerplate.
A First Example
Let’s rewrite our ConfigError type using thiserror.
First, import the derive macro:
use thiserror::Error;
Then derive the Error trait on the enum:
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("configuration file missing")]
MissingFile,
#[error("invalid port: {0}")]
InvalidPort(u16),
}
With just these annotations, thiserror automatically generates:
- a
Displayimplementation - an implementation of
std::error::Error
This replaces the manual implementations we wrote earlier.
The #[derive(Error)] Attribute
The #[derive(Error)] macro is the core feature of the crate. It tells
thiserror to generate the standard trait implementations needed for a Rust
error type.
Under the hood, the macro produces code equivalent to what we would write manually:
- a
Displayimplementation that formats each variant - an implementation of
std::error::Error
The result is a fully compliant error type that works with the rest of the Rust ecosystem.
The #[error("...")] Attribute
Each enum variant is annotated with an #[error("...")] attribute. This
attribute defines the user-facing error message associated with that
variant.
For example:
#[error("configuration file missing")]
MissingFile
When this error is displayed, it will produce the message:
configuration file missing
The attribute also supports formatting arguments. For tuple-style variants, fields can be referenced by position:
#[error("invalid port: {0}")]
InvalidPort(u16)
Here {0} refers to the first field of the variant, which is the invalid port
number.
If the error InvalidPort(90000) is displayed, the message becomes:
invalid port: 90000
With just a few annotations, we now have a clean, fully featured error type without writing any manual trait implementations.
5. Formatting Error Messages
One of the most convenient features of thiserror is how it lets you define
error messages directly on each enum variant using the #[error(...)]
attribute.
The syntax used inside #[error("...")] follows the same formatting rules as
Rust's standard formatting macros such as format!, println!, and write!.
This means you can insert values from the error variant directly into the
message.
This makes error definitions concise, expressive, and easy to read.
Positional Fields
For tuple-style variants, fields are accessed using positional indices.
For example:
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("invalid port: {0}")]
InvalidPort(u16),
}
Here the variant InvalidPort contains a single field of type u16. The
placeholder {0} refers to the first field of the variant.
If the error value is:
ConfigError::InvalidPort(90000)
then the displayed message will be:
invalid port: 90000
If a variant had multiple fields, you could reference them with {0}, {1},
{2}, and so on.
For example:
#[error("invalid range: {0} to {1}")]
InvalidRange(u32, u32)
This would allow both values to appear in the formatted message.
Named Fields
Another option is to use struct-style variants with named fields. These can make error definitions more readable, especially when there are multiple pieces of data involved.
For example:
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("invalid port: {port}")]
InvalidPort { port: u16 },
}
Here the error variant has a named field port. Inside the error message, the
field can be referenced directly using {port}.
If the error value is:
ConfigError::InvalidPort { port: 90000 }
the message will be:
invalid port: 90000Positional vs Named Fields
Both approaches are valid, and the choice is mostly a matter of style and clarity.
Tuple-style variants
InvalidPort(u16)
are concise and work well when there are only one or two fields.
Struct-style variants
InvalidPort { port: u16 }
can be easier to read when:
- the variant has multiple fields
- the meaning of the fields is not obvious
- the error carries several pieces of context
In larger error types, named fields often make the code easier to maintain.
The ability to embed fields directly into formatted error messages makes
thiserror both expressive and ergonomic. But one of its most powerful
features is still ahead: automatic conversion from other error types using the
#[from] attribute.
6. Automatic Conversions with #[from]
So far, our error types have represented failures specific to our own domain. In real programs, however, many errors originate from other libraries or the standard library.
For example:
- file operations may produce
std::io::Error - parsing numbers may produce
ParseIntError - network operations may produce HTTP errors
A common pattern in Rust is to wrap these lower-level errors inside your own error type. This lets your application expose a single unified error type while still preserving the original error internally.
This is where one of thiserror’s most powerful features comes in: the
#[from] attribute.
Wrapping External Errors
Consider the following error type:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("io error")]
Io(#[from] std::io::Error),
#[error("parse error")]
ParseInt(#[from] std::num::ParseIntError),
}
Here, the AppError enum includes variants that wrap errors from other parts
of the system.
Iowrapsstd::io::ErrorParseIntwrapsstd::num::ParseIntError
The important part is the #[from] attribute attached to the field.
What #[from] Generates
When you annotate a field with #[from], thiserror automatically generates
an implementation of the From trait.
For example, this line:
Io(#[from] std::io::Error)
generates something equivalent to:
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err)
}
}
This automatic conversion is extremely useful because it allows errors to be
converted implicitly when using the ? operator.
Error Propagation with ?
One of the main uses of Result in Rust is to propagate errors using the ?
operator.
For example:
fn parse_number(input: &str) -> Result<i32, AppError> {
let x: i32 = input.parse()?;
Ok(x)
}
The parse() method returns:
Result<i32, ParseIntError>
But our function returns:
Result<i32, AppError>
Normally this mismatch would cause a compilation error. However, because we
added #[from] std::num::ParseIntError, Rust knows how to convert the
ParseIntError into AppError.
So when ? encounters an error, it effectively performs:
ParseIntError -> AppError
before returning.
This mechanism allows errors to flow naturally through layers of a program without requiring explicit conversion code.
A Unified Error Type
Using #[from], it becomes easy to define a single application error type
that wraps multiple underlying errors.
For example, a CLI application might need to deal with:
- file system errors
- parsing errors
- configuration errors
- network errors
Instead of returning many different error types, the application can unify them
under a single enum like AppError.
Each lower-level error can then be converted automatically using #[from].
Why This Matters
This pattern provides several important benefits:
- Cleaner code: no manual error conversion
- Better composition: errors propagate naturally with
? - Clear boundaries: external errors are wrapped in your own type
- Unified error handling: the application deals with one error type
Because of this, #[from] is one of the most commonly used features of
thiserror.
7. Error Sources and Chains
In real applications, errors rarely happen in isolation. Often, one error occurs because another error happened first.
For example:
- a configuration loader fails
- because reading the file failed
- because the file does not exist
This creates a chain of errors, where a high-level error wraps a lower-level one. Rust’s error system supports this idea through the concept of error sources.
The trait std::error::Error includes a method called source() that allows
an error to expose its underlying cause.
fn source(&self) -> Option<&(dyn std::error::Error + 'static)>
If an error wraps another error, source() returns it. Otherwise it returns
None.
This mechanism allows tools and libraries to walk the chain of failures, which is extremely useful for debugging.
Defining an Error Source
thiserror makes it easy to define error sources using the #[source]
attribute.
For example:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config")]
Read {
#[source]
source: std::io::Error,
},
}
Here the Read variant represents a high-level failure: the configuration
could not be read.
However, the real cause is stored in the source field, which contains a
std::io::Error.
The #[source] attribute tells thiserror that this field represents the
underlying cause of the error.
What #[source] Does
When #[source] is applied to a field, thiserror automatically implements
the source() method from the std::error::Error trait.
Conceptually, it generates something like:
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::Read { source } => Some(source),
}
}
This allows the underlying error to be retrieved and inspected by error-handling tools.
Error Chains in Practice
Suppose our configuration loader fails because the file does not exist.
The error chain might look like this:
failed to read config
└── No such file or directory (os error 2)
The top-level error describes the high-level context ("failed to read config"), while the source error contains the low-level system failure.
This layered structure is extremely helpful when diagnosing problems, especially in larger applications where errors may pass through many layers of code.
Why Error Chains Matter
Error chains provide several important benefits:
- Better debugging: Developers can see both the high-level context and the underlying cause of a failure.
- Clear separation of concerns: Each layer of the program can add context without losing the original error.
- Better tooling: Logging frameworks and error-reporting tools can traverse the entire error chain automatically.
#[from] vs #[source]
It’s worth noting that #[from] (introduced in the previous section)
implicitly marks a field as a source.
For example:
Io(#[from] std::io::Error)
is equivalent to:
Io {
#[source]
source: std::io::Error
}
with the added bonus that #[from] also generates the From implementation.
In other words:
#[source]indicates an underlying error#[from]indicates an underlying error + automatic conversion
Both attributes help create structured error chains that make Rust applications easier to debug and maintain.
8. Transparent Errors
Sometimes an error type exists mainly to wrap another error without changing its message. In these cases, writing a new error message would add little value and might even hide useful information from the underlying error.
thiserror provides a convenient way to express this pattern using the
#[error(transparent)] attribute.
For example:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error(transparent)]
Io(#[from] std::io::Error),
}
Here, the Io variant wraps a std::io::Error, but the
#[error(transparent)] attribute tells thiserror to forward the display
message of the wrapped error directly.
In other words, the error message comes from the underlying std::io::Error,
not from the wrapper.
If the wrapped error prints something like:
No such file or directory (os error 2)
then the AppError::Io variant will display exactly the same message.
What “Transparent” Means
A transparent error essentially says:
“This error is just a wrapper. Use the inner error's message.”
Without transparent, you might define something like:
#[error("io error")]
Io(#[from] std::io::Error)
This would produce messages such as:
io error
While this might be acceptable in some contexts, it also loses the more detailed message that the underlying error might contain.
Using transparent preserves the full message from the original error while
still allowing your application to wrap it inside its own error type.
When to Use Transparent Errors
Transparent errors are useful in a few common situations.
Wrapper Error Types
Sometimes you want to expose a single application error type that aggregates errors from different parts of the system.
However, some of those errors already have good messages, and you don't want to rewrite them.
For example:
#[derive(Debug, Error)]
pub enum AppError {
#[error("configuration error")]
Config(ConfigError),
#[error(transparent)]
Io(#[from] std::io::Error),
}
Here:
- configuration errors get their own message
- I/O errors pass through unchanged
Pass-Through Errors
Transparent errors are also useful when a layer of your program simply forwards errors from a lower layer.
For example, a higher-level module might expose a unified error type but still want the original error message to reach the user.
In these cases, transparent errors let you preserve the original message while still structuring your error types properly.
A Good Rule of Thumb
A useful guideline is:
- Use a custom error message when you want to add context.
- Use
#[error(transparent)]when the underlying error message already provides the right information.
This balance allows your error types to remain structured and expressive while avoiding unnecessary duplication.
9. Designing Real Error Types
So far we’ve looked at the mechanics of thiserror: formatting messages,
wrapping other errors, and building error chains. The next step is learning how
to design good error types for real applications.
A well-designed error type should describe the meaningful failures of your program, while still allowing lower-level errors to propagate when necessary.
In Rust, the most common pattern is to use an enum that groups all the relevant failures for a particular component or application.
For example, a command-line application might define an error type like this:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CliError {
#[error("config file missing")]
ConfigMissing,
#[error("invalid port: {0}")]
InvalidPort(u16),
#[error("io error")]
Io(#[from] std::io::Error),
}
This enum represents the different ways the CLI can fail.
Domain Errors vs Infrastructure Errors
A useful mental model when designing error types is to distinguish between domain errors and infrastructure errors.
Domain errors represent failures that are specific to the logic of your application.
Examples include:
- missing configuration
- invalid user input
- invalid command arguments
- malformed data
In the example above:
ConfigMissing
InvalidPort(u16)
are domain errors. They describe problems related to the meaning of the program’s inputs.
Infrastructure errors, on the other hand, come from the systems your program interacts with.
Examples include:
- file system errors
- network errors
- database errors
- serialization errors
These are typically errors produced by other libraries or the standard library.
In our example:
Io(#[from] std::io::Error)
wraps an infrastructure error originating from the operating system.
Grouping Failures in a Single Error Type
A good application error type usually groups both kinds of failures:
- domain-specific problems that your program defines
- lower-level errors that come from external libraries
This gives you a single error type that represents everything that can go wrong in a particular layer of your program.
For example, a CLI application might fail because:
- the configuration file is missing
- the configuration contains an invalid value
- the file system operation fails
All of these failures can be represented in one enum.
This has several advantages:
- Clear API boundaries: Functions can return
Result<T, CliError>instead of many different error types. - Better structure: Callers can match on domain errors when they want special handling.
- Natural error propagation: Infrastructure errors can propagate automatically using
#[from].
Adding Context at Higher Levels
As errors move through layers of a program, higher-level code can add additional context.
For example:
- a configuration module may define
ConfigError - a CLI module may wrap it inside
CliError
Each layer can describe the failure in terms of its own responsibilities while preserving the underlying cause.
This layered design leads to clearer error messages and better debugging information.
A Simple Guideline
When designing error types, a useful guideline is:
- define domain errors explicitly
- wrap external errors using
#[from] - group related failures into a single enum per subsystem
Following this pattern keeps error handling structured and maintainable, especially as a codebase grows.
10. thiserror vs anyhow
At this point, you might be wondering how thiserror relates to another very
popular crate in the Rust ecosystem: anyhow.
Both crates deal with error handling, but they serve different purposes and are typically used in different layers of a program.
A useful rule of thumb is:
| crate | use case |
|---|---|
thiserror | defining structured error types (especially in libraries) |
anyhow | handling errors in applications |
Understanding this distinction helps you design cleaner APIs and more maintainable code.
thiserror: Structured Error Types
The purpose of thiserror is to help you define error types.
When you write a library or a reusable module, you usually want callers to understand what kinds of failures are possible. That means returning a specific error type.
For example:
pub fn parse_port(s: &str) -> Result<u16, ConfigError>
Here the function communicates something precise: if parsing fails, it will
return a ConfigError.
This allows callers to:
- match on specific variants
- recover from certain failures
- handle errors programmatically
Because of this, libraries should usually expose structured error types,
and thiserror makes defining them ergonomic.
anyhow: Application Error Handling
Applications often have different requirements.
At the top level of a program—such as in a CLI tool—errors are typically reported to the user and the program exits. The application does not need to match on every possible failure.
Instead of defining a large error enum, applications can use anyhow::Error,
which is a type-erased error container capable of holding any error.
This leads to very concise code.
For example, a CLI program might define its entry point like this:
fn main() -> anyhow::Result<()> {
let port = parse_port("8080")?;
println!("Port: {}", port);
Ok(())
}
Here, anyhow::Result is simply a convenient alias:
Result<T, anyhow::Error>
If parse_port returns a ConfigError, anyhow automatically converts it
into anyhow::Error.
The application doesn’t need to care about the exact error type. It simply propagates the error upward and prints it.
A Common Pattern
In real Rust projects, you’ll often see these two crates used together.
- Libraries and modules: define structured errors with
thiserror - Applications: aggregate and propagate errors using
anyhow
For example:
library code
└── ConfigError (defined with thiserror)
application code
└── main() -> anyhow::Result<()>
Errors defined with thiserror travel through the program, and anyhow
collects them at the top level.
This pattern provides the best of both worlds:
- structured errors where they matter
- simple error propagation at the application boundary
When to Use Each
A practical guideline is:
Use thiserror when:
- designing library APIs
- defining domain error types
- modeling specific failure cases
Use anyhow when:
- writing binaries or CLI tools
- you mainly want to propagate and print errors
- you don’t need callers to match on error variants
With this distinction in mind, you now have the core mental model behind modern Rust error handling: structured error types inside libraries, flexible error aggregation at the application boundary.
11. Best Practices
Although thiserror is extremely useful, it is not always the right tool. Like
many things in Rust, the best choice depends on the level of structure your
program actually needs.
In some situations, defining a full custom error type adds unnecessary
complexity. Knowing when not to use thiserror is just as important as
knowing when to use it.
Quick Scripts
For small scripts or experimental programs, creating a structured error enum can be overkill.
Imagine a short utility that reads a file, parses a few numbers, and prints a
result. The program might only have one place where errors are handled: the
top-level main function.
In this case, defining an entire error type like:
#[derive(Debug, Error)]
pub enum ScriptError {
Io(#[from] std::io::Error),
Parse(#[from] std::num::ParseIntError),
}
doesn’t provide much benefit. The script is unlikely to need fine-grained error handling.
Instead, it is usually simpler to rely on a generic error container such as
anyhow::Error:
fn main() -> anyhow::Result<()> {
let contents = std::fs::read_to_string("numbers.txt")?;
println!("{}", contents);
Ok(())
}
This keeps the code short and easy to write.
Throwaway Code
Sometimes you are writing code that is temporary by nature:
- a prototype
- a one-off data migration
- a quick debugging tool
- a local experiment
In these situations, spending time designing structured error types rarely pays
off. A flexible error type like anyhow::Error is usually sufficient.
If the code later grows into a real project, you can always introduce proper error types at that point.
Binaries Using anyhow
Many Rust applications, especially CLI tools, use anyhow for top-level error
handling.
In these programs, errors often propagate upward until they reach main, where
they are printed and the process exits.
For example:
fn main() -> anyhow::Result<()> {
run()?;
Ok(())
}
If the program is relatively simple and does not need to match on specific error variants, defining custom error enums may not provide much value.
Instead, you can let different parts of the program return different error
types and rely on anyhow to collect them.
The Trade-off
Using thiserror provides structure and clarity, but it also introduces
additional types and design decisions.
For small programs, this extra structure may not be necessary.
A good rule of thumb is:
Use thiserror when:
- your code defines a reusable library
- callers need to react to specific failure cases
- you want clearly modeled domain errors
Avoid thiserror when:
- the program is small and self-contained
- errors are only printed to the user
- structured error handling adds little value
In practice, many Rust developers start a small project using anyhow, and
only introduce thiserror once the code grows large enough that structured
error types become useful.
12. Final Example
Let’s bring everything together and build a realistic error type for a command-line application.
A typical CLI tool might need to deal with several different kinds of failures:
- configuration problems
- invalid user input
- file system errors
- parsing errors
Using thiserror, we can define a single error type that captures all of these
possibilities.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CliError {
#[error("configuration file missing")]
ConfigMissing,
#[error("invalid port: {0}")]
InvalidPort(u16),
#[error("io error")]
Io(#[from] std::io::Error),
#[error("parse error")]
ParseInt(#[from] std::num::ParseIntError),
}
This error type demonstrates several patterns we covered throughout the article:
-
Domain errors
ConfigMissingInvalidPort(u16)
-
Infrastructure errors
Io(#[from] std::io::Error)ParseInt(#[from] std::num::ParseIntError)
-
Automatic conversions
#[from]allows external errors to be converted intoCliError.
This gives the CLI a single unified error type that represents everything that can go wrong.
Using the Error Type in Practice
Now let’s look at how this error type can be used in a real function.
Imagine we want to read a port number from a configuration file and parse it.
use std::fs;
fn read_port(path: &str) -> Result<u16, CliError> {
let contents = fs::read_to_string(path)?;
let port: u16 = contents.trim().parse()?;
if port == 0 {
return Err(CliError::InvalidPort(port));
}
Ok(port)
}
Notice how clean the code is:
fs::read_to_stringmay produce anio::Error.parse()may produce aParseIntError
Because we added #[from] to our error enum, both of these errors are
automatically converted into CliError when the ? operator is used.
No manual conversions are needed.
How Errors Flow Through the Program
When an error occurs, it moves through the program like this:
std::io::Error
↓
CliError::Io
↓
returned from read_port()
or
ParseIntError
↓
CliError::ParseInt
↓
returned from read_port()
This makes it easy for higher-level code to either:
- propagate the error further, or
- match on specific variants and handle them differently.
A Simple main Function
At the top level of a CLI application, errors are often printed and the program exits.
For example:
fn main() -> Result<(), CliError> {
let port = read_port("config.txt")?;
println!("Server will run on port {}", port);
Ok(())
}
If something goes wrong, Rust will display the formatted error message defined in the enum.
The Big Picture
With just a small amount of code, we now have:
- structured error types
- meaningful error messages
- automatic error conversions
- clean error propagation with
? - a unified error model for the entire CLI
This is exactly what thiserror was designed to make easy.
Instead of writing repetitive boilerplate, we can focus on modeling the real
failures of our program, while Rust and thiserror take care of the rest.