297: The thiserror Pattern
Tutorial Video
Text description (accessibility)
This video demonstrates the "297: The thiserror Pattern" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Implementing `Display`, `Debug`, `Error`, and `From` impls for every error type is mechanical boilerplate. Key difference from OCaml: 1. **Boilerplate reduction**: `thiserror` eliminates repetitive `Display` impls; OCaml's `ppx_sexp_conv` or `ppx_deriving` provide similar code generation.
Tutorial
The Problem
Implementing Display, Debug, Error, and From impls for every error type is mechanical boilerplate. The thiserror crate generates this boilerplate via derive macros. This example implements what thiserror generates manually — understanding the generated code demystifies the macro and provides a foundation for working with the pattern in production codebases where thiserror is a standard dependency.
🎯 Learning Outcomes
#[derive(thiserror::Error)] generates for common patterns#[error("message {field}")] template patterns manually#[from] conversions that wrap nested error types automaticallyError impls are needed vs when thiserror sufficesCode Example
#[derive(thiserror::Error, Debug)]
pub enum DbError {
#[error("connection to '{host}' failed")]
ConnectionFailed { host: String },
#[error("query failed: {0}")]
QueryFailed(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}Key Differences
thiserror eliminates repetitive Display impls; OCaml's ppx_sexp_conv or ppx_deriving provide similar code generation.thiserror's #[error("message {field}")] embeds formatting directly in the variant definition.#[from] on a field generates both From impl and sets source() — two things in one annotation.thiserror is for library errors (precise, structured); anyhow is for application errors (flexible, dynamic).OCaml Approach
OCaml uses ppx_deriving or plain variant types with a to_string or pp function. There is no standard equivalent to thiserror:
type db_error =
| ConnectionFailed of { host: string }
| QueryFailed of string
let string_of_db_error = function
| ConnectionFailed { host } -> Printf.sprintf "connection to '%s' failed" host
| QueryFailed sql -> Printf.sprintf "query failed: %s" sql
Full Source
#![allow(clippy::all)]
//! # thiserror-style derive macros
//!
//! Manually implementing what `#[derive(thiserror::Error)]` generates.
use std::error::Error;
use std::fmt;
/// Database error - what thiserror would generate for:
/// #[derive(thiserror::Error, Debug)]
/// pub enum DbError {
/// #[error("connection to '{host}' failed")]
/// ConnectionFailed { host: String },
/// #[error("query failed: {0}")]
/// QueryFailed(String),
/// }
#[derive(Debug)]
pub enum DbError {
ConnectionFailed { host: String },
QueryFailed(String),
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DbError::ConnectionFailed { host } => {
write!(f, "connection to '{}' failed", host)
}
DbError::QueryFailed(sql) => write!(f, "query failed: {}", sql),
}
}
}
impl Error for DbError {}
/// Application error wrapping DbError
#[derive(Debug)]
pub enum AppError {
Db(DbError),
Auth(String),
Config { key: String, reason: String },
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Db(e) => write!(f, "database error: {}", e),
AppError::Auth(msg) => write!(f, "auth error: {}", msg),
AppError::Config { key, reason } => {
write!(f, "config error for '{}': {}", key, reason)
}
}
}
}
impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
AppError::Db(e) => Some(e),
_ => None,
}
}
}
// From impl (what #[from] generates)
impl From<DbError> for AppError {
fn from(e: DbError) -> Self {
AppError::Db(e)
}
}
/// Connect to database
pub fn connect(host: &str) -> Result<(), DbError> {
if host == "bad-host" {
Err(DbError::ConnectionFailed {
host: host.to_string(),
})
} else {
Ok(())
}
}
/// Run application
pub fn run(host: &str) -> Result<(), AppError> {
connect(host)?; // From<DbError> for AppError
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_db_display() {
let e = DbError::ConnectionFailed {
host: "localhost".to_string(),
};
assert!(format!("{}", e).contains("localhost"));
}
#[test]
fn test_from_conversion() {
let db_err = DbError::QueryFailed("SELECT *".to_string());
let app_err: AppError = db_err.into();
assert!(matches!(app_err, AppError::Db(_)));
}
#[test]
fn test_source_chain() {
let app_err = AppError::Db(DbError::QueryFailed("bad".to_string()));
assert!(app_err.source().is_some());
}
#[test]
fn test_run_ok() {
assert!(run("good-host").is_ok());
}
#[test]
fn test_run_err() {
assert!(run("bad-host").is_err());
}
#[test]
fn test_config_error() {
let e = AppError::Config {
key: "port".to_string(),
reason: "missing".to_string(),
};
assert!(format!("{}", e).contains("port"));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_db_display() {
let e = DbError::ConnectionFailed {
host: "localhost".to_string(),
};
assert!(format!("{}", e).contains("localhost"));
}
#[test]
fn test_from_conversion() {
let db_err = DbError::QueryFailed("SELECT *".to_string());
let app_err: AppError = db_err.into();
assert!(matches!(app_err, AppError::Db(_)));
}
#[test]
fn test_source_chain() {
let app_err = AppError::Db(DbError::QueryFailed("bad".to_string()));
assert!(app_err.source().is_some());
}
#[test]
fn test_run_ok() {
assert!(run("good-host").is_ok());
}
#[test]
fn test_run_err() {
assert!(run("bad-host").is_err());
}
#[test]
fn test_config_error() {
let e = AppError::Config {
key: "port".to_string(),
reason: "missing".to_string(),
};
assert!(format!("{}", e).contains("port"));
}
}
Deep Comparison
OCaml vs Rust: thiserror Pattern
Pattern: Derive-Based Error Definition
Rust (with thiserror)
#[derive(thiserror::Error, Debug)]
pub enum DbError {
#[error("connection to '{host}' failed")]
ConnectionFailed { host: String },
#[error("query failed: {0}")]
QueryFailed(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
Rust (manual equivalent)
#[derive(Debug)]
pub enum DbError {
ConnectionFailed { host: String },
QueryFailed(String),
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DbError::ConnectionFailed { host } =>
write!(f, "connection to '{}' failed", host),
DbError::QueryFailed(sql) =>
write!(f, "query failed: {}", sql),
}
}
}
impl Error for DbError {}
impl From<std::io::Error> for DbError { ... }
OCaml
type db_error =
| ConnectionFailed of string
| QueryFailed of string
let string_of_db_error = function
| ConnectionFailed host -> Printf.sprintf "connection to '%s' failed" host
| QueryFailed sql -> Printf.sprintf "query failed: %s" sql
Key Differences
| Concept | OCaml | Rust (manual) | Rust (thiserror) |
|---|---|---|---|
| Error messages | Ad-hoc function | impl Display | #[error("...")] |
| From conversion | Manual | impl From | #[from] |
| Source chain | Manual field | fn source() | Auto from #[from] |
| Boilerplate | Minimal | ~30 lines | ~5 lines |
Exercises
thiserror would generate for a FileError with NotFound, PermissionDenied, and IoError(#[from] std::io::Error) variants.#[derive(thiserror::Error)] usage with the manual implementation in this example.AppError wraps DbError and IoError, implementing all necessary From conversions manually.