Box<dyn Error> is fast and convenient, but there are limits when the library grows. If the caller needs to distinguish between types of errors and handle them differently, domain-specific error types are necessary. In this section, we will learn how to define a clean enum error with the thiserror crate, and automatically implement the From trait with #[from] to naturally integrate with ?.
Learning Objectives
- Understand the meaning of the standard library
std::error::Errortrait. - Define enum error types using the
thiserror::Errormacro. - Automatically generate
Displaymessages with the#[error("...")]attribute. - Smoothly convert
?by automatically implementingFromwith#[from].
Key Concepts
1) std::error::Error Trait
Almost all errors in the standard library implement this trait. You can implement it yourself, but it’s burdensome, so we use the help of macros.
2) What thiserror Does
- Automatically implement
Errortrait with#[derive(Error)]. - Automatically implement
Displaywith#[error("Message {0}")]. - Automatically implement
From<Original_Error>with#[from]→ automatic conversion to?.
Cargo.toml:
[dependencies]
thiserror = "1"
3) Basic Definition Pattern
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MyError {
#[error("Integer parsing failed: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Value out of range: {0}")]
OutOfRange(i32),
}
- Use
#[from]for variants that wrap external errors. - For a domain-specific variant, include the data directly and interpolate it into the message as
{0}, etc.
4) Library vs Application
| Case | Recommendation |
|---|---|
| Library (reusable) | Custom error enum per domain (thiserror) |
| Application (executable binary) | Single dynamic error like anyhow::Result — this tutorial does not cover this part |
Library users should be able to see what kinds of failures are possible by type and branch accordingly.
5) Combining with ?
fn read_and_double(path: &str) -> Result<i32, AppError> {
let s = std::fs::read_to_string(path)?; // io::Error → AppError
let n: i32 = s.trim().parse()?; // ParseIntError → AppError
Ok(n * 2)
}
Errors from two sources are merged into one type in three lines.
Examples
Example 1 — ex01_define.rs: Defining a custom error
//Example 18-1: Defining a custom error enum with thiserror
//
//With just `#[derive(Error)]` and `#[error("...")]`, the Display + Error traits are automatically implemented.
use thiserror::Error;
#[derive(Debug, Error)]
enum AgeError {
#[error("Age must be 0 or greater (currently {0})")]
Negative(i32),
#[error("Age is too large (maximum 150, currently {0})")]
TooLarge(i32),
}
fn validate_age(n: i32) -> Result<u8, AgeError> {
if n < 0 {
return Err(AgeError::Negative(n));
}
if n > 150 {
return Err(AgeError::TooLarge(n));
}
Ok(n as u8)
}
fn main() {
for n in [25, -3, 200] {
match validate_age(n) {
Ok(v) => println!("{n} → Valid: {v}"),
//{} is Display, {:?} is Debug — thiserror neatly provides both
Err(e) => println!("{n} → Error: {e}"),
}
}
}
Example 2 — ex02_from.rs: Automatic conversion to #[from]
//Example 18-2: Automatic From implementation with #[from] + automatic conversion with ?
//
//When wrapping an external error type (e.g., ParseIntError) as one variant of our enum
//With just one line of `#[from]`, the From trait is automatically created.
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
enum MyError {
#[error("Integer parsing failed: {0}")]
Parse(#[from] ParseIntError), //? ParseIntError → automatically converts to MyError
#[error("Value out of range: {0}")]
OutOfRange(i32),
}
fn parse_positive(s: &str) -> Result<u32, MyError> {
let n: i32 = s.parse()?; //ParseIntError is automatically converted to MyError
if n < 0 {
return Err(MyError::OutOfRange(n));
}
Ok(n as u32)
}
fn main() {
for s in ["42", "abc", "-3"] {
match parse_positive(s) {
Ok(v) => println!("{s} → Ok({v})"),
Err(e) => println!("{s} → Err: {e}"),
}
}
}
Example 3 — ex03_app.rs: Merging errors from two sources
//Example 18-3: Combining Two Types of External Errors into One Custom Error
//
//In practice, errors from different sources, like IO and parsing, occur in the same function.
//If you group them into the same custom error, it is easier for the caller to handle.
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("IO failure: {0}")]
Io(#[from] std::io::Error),
#[error("Parsing failure: {0}")]
Parse(#[from] ParseIntError),
}
fn read_and_double(path: &str) -> Result<i32, AppError> {
let s = std::fs::read_to_string(path)?; // io::Error → AppError
let n: i32 = s.trim().parse()?; // ParseIntError → AppError
Ok(n * 2)
}
fn main() {
let path = std::env::temp_dir().join("lecture18_ex03.txt");
std::fs::write(&path, "10").expect("Creating a temporary file");
let path_str = path.to_str().expect("UTF-8 path");
match read_and_double(path_str) {
Ok(v) => println!("Result = {v}"),
Err(e) => println!("Failure: {e}"),
}
//Intentionally using incorrect content to also show a parsing failure.
std::fs::write(&path, "not-a-number").expect("Rewriting the temporary file");
match read_and_double(path_str) {
Ok(v) => println!("Result = {v}"),
Err(e) => println!("Failure: {e}"),
}
}
Common Mistakes
Q. What happens if you attach #[from] to the same type on two variants?
A. Implementing From will conflict and cause a compile error. If you want to map the same external error type to two variants, attach #[from] to only one variant and do not implement From directly for the other.
Q. Can I use field names in the Display message?
A. Yes. If it is a struct-like variant, you can interpolate it directly into {field}.
#[error("Age {age} is outside the range {min}~{max}")]
OutOfRange { age: i32, min: i32, max: i32 },
Q. Which is better, Box<dyn Error> or a custom error enum?
A. For quick prototyping, using Box<dyn Error> in a binary main is convenient. For libraries and reusable code, a custom enum is recommended so that the caller can distinguish the types.
Q. How does the cause chain of error messages work?
A. You can point to the cause error with the #[source] property. #[from], which automatically sets the source when converting ?, is usually more convenient.
Summary
- Creating domain-specific custom error enums allows callers to handle them by type.
- With
thiserror::Error+#[error("...")]+#[from], boilerplate code is almost eliminated. - Since
?performs automatic conversion, errors from multiple sources can be neatly combined in a single line. - Use a custom enum for libraries, and
Box<dyn Error>is fine for short application mains.
Try it yourself
Solve the assignment below. The reference solution is shown below.