How to use thiserror crate in Rust error types

Define custom Rust error enums using the thiserror crate's derive macro and error attribute annotations.

How to use thiserror crate in Rust error types

You're building a command-line tool that reads a configuration file, parses JSON, and validates the data. Everything works until the config is missing. You want to return an error that says "Config not found" but also handle the case where the JSON is malformed or the network drops. Rust requires a single error type for your function's return value. You could write a massive impl std::fmt::Display and impl std::error::Error by hand, copying boilerplate until your fingers cramp. Or you can let thiserror generate the boring parts so you can focus on the logic.

When one error type isn't enough

Rust's type system is strict. A function returns Result<T, E>, where E is a single type. If your code can fail in three different ways, you need one type that represents all three. An enum is the natural choice. Each variant represents a failure mode. The problem is that Rust's error ecosystem expects more than just an enum. It expects the type to implement std::fmt::Display for user-friendly messages and std::error::Error to mark it as a proper error. It also expects From implementations if you want to use the ? operator to convert external errors automatically.

Writing these implementations manually is repetitive. You match on variants, format strings, and delegate to inner errors. The logic is mechanical. thiserror automates this. You write the enum and add attributes. The macro generates the traits. You get a clean API with zero boilerplate.

The macro that writes your traits

thiserror is a derive macro. You add #[derive(thiserror::Error)] to your enum. The macro reads the enum structure and the attributes on each variant. It generates the trait implementations the compiler demands.

Think of it like a legal form. You fill in the specific details of your case. The form auto-generates the standardized clauses required by the court. You don't write the clauses yourself. You just provide the facts.

The macro generates three main things:

  • impl std::fmt::Display: Formats the error message based on #[error(...)] attributes.
  • impl std::error::Error: Marks the type as an error. Optionally implements source() for error chaining.
  • impl From<InnerError>: Generated by #[from] attributes. Enables the ? operator.

You control the behavior through attributes. The macro handles the syntax.

Minimal example

Start with a simple enum. Derive Debug and Error. Add #[error(...)] to define the message.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    // Attribute defines the Display message.
    // {0} references the first field in the tuple variant.
    #[error("Config file '{0}' not found")]
    ConfigNotFound(String),

    // Use {0:?} to format the field with Debug.
    #[error("Invalid port number: {0:?}")]
    InvalidPort(u16),
}

fn main() {
    let err = AppError::ConfigNotFound("config.yaml".into());
    
    // Display trait prints the formatted message.
    println!("{}", err);
    // Output: Config file 'config.yaml' not found

    // Debug trait prints the enum structure.
    println!("{:?}", err);
    // Output: ConfigNotFound("config.yaml")
}

Derive Debug alongside Error. The macro demands it. If you forget, the compiler rejects the code with a trait bound error. Always pair them.

What the compiler sees

When you compile, thiserror expands before the type checker runs. The macro generates code that looks like this:

// Generated by thiserror (conceptual expansion)
impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::ConfigNotFound(s) => write!(f, "Config file '{}' not found", s),
            AppError::InvalidPort(p) => write!(f, "Invalid port number: {:?}", p),
        }
    }
}

impl std::error::Error for AppError {}

The macro creates a match statement inside Display::fmt. Each arm corresponds to a variant. The format string from the attribute becomes the write! call. Field references like {0} map to the variant's data.

The Error impl is empty unless you use #[source] or #[backtrace]. An empty impl is enough to mark the type as an error. This allows the type to be used with Box<dyn std::error::Error> and other ecosystem tools.

Real-world error handling

Real code deals with other crates. You'll encounter io::Error from file operations or serde::Error from parsing. thiserror handles these with #[from]. This attribute generates a From implementation. That's the magic behind the ? operator.

use std::fs;
use std::io;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    // #[from] generates impl From<io::Error> for AppError.
    // The variant must hold the exact type.
    #[error("IO error: {0}")]
    Io(#[from] io::Error),

    // Wrap serde errors with context.
    #[error("Parse error: {0}")]
    Parse(#[from] serde_json::Error),

    // Validation errors come from your logic.
    #[error("Validation failed: {0}")]
    Validation(String),
}

fn load_config() -> Result<serde_json::Value, AppError> {
    // ? operator converts io::Error to AppError::Io automatically.
    let content = fs::read_to_string("config.json")?;
    
    // ? operator converts serde_json::Error to AppError::Parse automatically.
    let value = serde_json::from_str(&content)?;
    
    Ok(value)
}

The ? operator calls From::from when types don't match. #[from] provides that conversion. You can chain errors effortlessly. The variant holds the inner error, preserving the details.

Sometimes you don't want to add context. You just want to pass an error through. Use #[error(transparent)]. This delegates Display and source() to the inner error.

#[derive(Debug, Error)]
pub enum AppError {
    // Transparent passes through the inner error's Display and Source.
    // No extra message is added.
    #[error(transparent)]
    Database(#[from] tokio_postgres::Error),
}

Convention matters here. Wrap errors when you add value. Pass them through when you don't. If you wrap an error with a generic message like "Error: {0}", you're adding noise. Use transparent instead. The community expects transparent for passthrough variants. It keeps error chains clean.

Pitfalls and compiler errors

thiserror is robust, but it has rules. Violating them leads to compile errors.

You must derive Debug. The macro checks for this. If you forget, you get E0277 (trait bound not satisfied) pointing to the derive macro. The error message says the type doesn't implement Debug. Add #[derive(Debug)] and the error vanishes.

The #[from] attribute requires the variant to hold the exact type. If you have #[from] io::Error but the variant is Io(String), the macro fails. The variant must be Io(io::Error). You can't convert types in #[from]. Use a custom From impl or a helper function if you need conversion logic.

Format strings in #[error(...)] are strict. You can reference fields by index ({0}) or name. You cannot call methods or do math. If you try #[error("Error: {}", self.code.to_string())], the macro emits a compile error. Keep formatting simple. Do heavy lifting in the variant data or a helper method.

Error chaining requires #[source]. If you want Error::source() to return an inner error, you must mark it. #[from] implies #[source] automatically. If you wrap an error manually without #[from], you need #[source] explicitly.

#[derive(Debug, Error)]
pub enum AppError {
    #[error("Connection failed")]
    Connection {
        // Explicit source for manual wrapping.
        #[source]
        inner: io::Error,
    },
}

Struct variants work too. Use named fields for clarity. The macro supports both tuple and struct variants. Choose based on readability. Tuple variants are concise. Struct variants are explicit.

Choosing your error strategy

Rust has multiple error tools. Pick the one that matches your boundary.

Use thiserror when you are defining a library's public error type. It gives you a clean enum API that users can match on. It keeps your crate's error surface stable and documented.

Use anyhow when you are writing an application binary and just need to bubble errors up to main. It saves you from defining enums for internal flow. It wraps errors with context automatically.

Use manual trait implementations when you need custom behavior that macros can't express, though this is rare. You might need this for complex error chains or integration with legacy systems.

Use snafu when you want automatic context attachment and backtraces on every error conversion. It generates more code but reduces repetition in large codebases. It's heavier than thiserror but more feature-rich.

Libraries define enums. Applications bubble up. Pick the tool that matches your boundary.

Where to go next