Simplifying Rust Error Handling with Thiserror

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 { 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 { // 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 { 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 { // 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

Apr 27, 2025 - 19:28
 0
Simplifying Rust Error Handling with Thiserror

Cover

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 and Error 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

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!

Try Leapcell

Follow us on X: @LeapcellHQ

Read on our blog