Thiserror: Effective Error Management in Rust
James Reed
Infrastructure Engineer · Leapcell

Error Handling
In programming, error handling is a crucial part. In Rust, we often use the Result
and Option
types for error handling. However, sometimes we need to create custom error types. This is where the thiserror
crate comes into play, significantly simplifying the code. At the end of the article, there is a comparison between using thiserror
and not using it.
Overview of the thiserror
Crate
The main goal of the thiserror
crate is to simplify the creation and handling of custom errors in Rust. To use thiserror
in your project, first add it to your Cargo.toml
:
[dependencies] thiserror = "1.0"
Creating Custom Errors
The thiserror
crate, by combining Rust's derive
macros and custom attributes, provides developers with the ability to quickly create custom error types.
Example:
use thiserror::Error; // Definition of a custom error type #[derive(Error, Debug)] pub enum MyError { // Description for DataNotFound error #[error("data not found")] DataNotFound, // Description for InvalidInput error #[error("invalid input")] InvalidInput, } // Example function showing how to use custom errors fn search_data(query: &str) -> Result<(), MyError> { if query.is_empty() { // Return InvalidInput error when the query is empty return Err(MyError::InvalidInput); } // The actual data query logic is omitted here // ... // Return DataNotFound error when data is not found Err(MyError::DataNotFound) }
Here, MyError
is the custom error enum we defined. Each variable is annotated with an #[error("...")]
attribute that provides the message to be displayed when the error is triggered.
Nested Errors
Error chaining allows capturing and responding to errors propagated from underlying libraries or functions. thiserror
provides a way to specify that an error is caused by another error.
Example:
use std::io; use thiserror::Error; // Definition of a custom error type #[derive(Error, Debug)] pub enum MyError { // Description for IoError, which contains a nested io::Error #[error("I/O error occurred")] IoError(#[from] io::Error), } // Example function showing how to use nested errors fn read_file(file_path: &str) -> Result<String, MyError> { // If fs::read_to_string returns an error, we use MyError::from to convert it into MyError::IoError std::fs::read_to_string(file_path).map_err(MyError::from) }
The #[from]
attribute indicates that an io::Error
can be automatically converted into MyError::IoError
.
Dynamic Error Messages
Dynamic error messages allow generating error messages based on runtime data.
Example:
use thiserror::Error; // Definition of a custom error type #[derive(Error, Debug)] pub enum MyError { // Description for FailedWithCode, where {0} will be dynamically replaced with the actual code value #[error("failed with code: {0}")] FailedWithCode(i32), } // Example function showing how to use dynamic error messages fn process_data(data: &str) -> Result<(), MyError> { let error_code = 404; // Some computed error code // Use the dynamic error_code to create a FailedWithCode error Err(MyError::FailedWithCode(error_code)) }
Cross-Library and Cross-Module Error Handling
thiserror
also supports automatic conversion from other error types. This is particularly useful for error handling across modules or libraries.
Example:
use thiserror::Error; // Simulated error type imported from another library #[derive(Debug, Clone)] pub struct OtherLibError; // Definition of a custom error type #[derive(Error, Debug)] pub enum MyError { // Description for OtherError, which directly inherits from its inner error type #[error(transparent)] OtherError(#[from] OtherLibError), } // Example function showing how to convert from another error type fn interface_with_other_lib() -> Result<(), MyError> { // Call a function from another library... // If that function returns an error, we use MyError::from to convert it into MyError::OtherError Err(MyError::from(OtherLibError)) }
The #[error(transparent)]
attribute means that this error simply acts as a container for another error, and its error message will be directly inherited from its "source" error.
Comparison with Other Error Handling Crates
Although thiserror
is very useful, it is not the only error handling crate available. For example, anyhow
is another popular crate used for rapid prototyping and application development. However, thiserror
provides more flexible error definitions and pattern matching capabilities.
Practical Case
Consider an operation involving reading and parsing a file. We need to handle potential I/O errors and parsing errors.
Example:
use std::fs; use thiserror::Error; // Simulated parse error type imported from another part #[derive(Debug, Clone)] pub struct ParseDataError; // Definition of a custom error type #[derive(Error, Debug)] pub enum MyError { // Description for IoError, containing a nested io::Error #[error("I/O error occurred")] IoError(#[from] io::Error), // Description for ParseError, containing a nested ParseDataError #[error("failed to parse data")] ParseError(#[from] ParseDataError), } // Read a file and attempt to parse its contents fn read_and_parse(filename: &str) -> Result<String, MyError> { // Read file contents, may throw an I/O error let content = fs::read_to_string(filename)?; // Attempt to parse contents, may throw a parse error parse_data(&content).map_err(MyError::from) } // Simulated data parsing function, which always returns an error here fn parse_data(content: &str) -> Result<String, ParseDataError> { Err(ParseDataError) } // Main function demonstrating how to use the above error handling logic fn main() { match read_and_parse("data.txt") { Ok(data) => println!("Data: {}", data), Err(e) => eprintln!("Error: {}", e), } }
Comparison: Using thiserror
vs Not Using thiserror
Let’s consider a more complex example involving multiple possible errors arising from multiple sources.
Suppose you are writing an application that needs to fetch data from a remote API and then save the data to a database. Each step can fail and return different types of errors.
Code Without Using thiserror
:
use std::fmt; #[derive(Debug)] enum DataFetchError { HttpError(u16), Timeout, InvalidPayload, } impl fmt::Display for DataFetchError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::HttpError(code) => write!(f, "HTTP error with code: {}", code), Self::Timeout => write!(f, "Data fetching timed out"), Self::InvalidPayload => write!(f, "Invalid payload received"), } } } impl std::error::Error for DataFetchError {} #[derive(Debug)] enum DatabaseError { ConnectionFailed, WriteFailed(String), } impl fmt::Display for DatabaseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::ConnectionFailed => write!(f, "Failed to connect to database"), Self::WriteFailed(reason) => write!(f, "Failed to write to database: {}", reason), } } } impl std::error::Error for DatabaseError {}
Code Using thiserror
:
use thiserror::Error; #[derive(Debug, Error)] enum DataFetchError { #[error("HTTP error with code: {0}")] HttpError(u16), #[error("Data fetching timed out")] Timeout, #[error("Invalid payload received")] InvalidPayload, } #[derive(Debug, Error)] enum DatabaseError { #[error("Failed to connect to database")] ConnectionFailed, #[error("Failed to write to database: {0}")] WriteFailed(String), }
Analysis
- Reduced Code: For each error type, we no longer need separate
Display
andError
trait implementations. This greatly reduces boilerplate code and improves code readability. - Error Messages Co-located with Definitions: Using
thiserror
, we can write the error message directly next to the error definition. This makes the code more organized and easier to locate and modify. - Increased Maintainability: If we need to add or remove error types, we only need to modify the enum definition and update the error messages, without needing to change other parts of the code.
Thus, as our error types and scenarios become more complex, the advantages of using thiserror
become more apparent.
We are Leapcell, your top choice for hosting Rust projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ