ExamplesBy LevelBy TopicLearning Paths
297 Intermediate

297: The thiserror Pattern

Functional Programming

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

  • • Understand what code #[derive(thiserror::Error)] generates for common patterns
  • • Implement error formatting with #[error("message {field}")] template patterns manually
  • • Implement #[from] conversions that wrap nested error types automatically
  • • Recognize when manual Error impls are needed vs when thiserror suffices
  • Code 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

  • Boilerplate reduction: thiserror eliminates repetitive Display impls; OCaml's ppx_sexp_conv or ppx_deriving provide similar code generation.
  • Template syntax: thiserror's #[error("message {field}")] embeds formatting directly in the variant definition.
  • Source chaining: #[from] on a field generates both From impl and sets source() — two things in one annotation.
  • Library boundary: 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"));
        }
    }
    ✓ Tests Rust test suite
    #[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

    ConceptOCamlRust (manual)Rust (thiserror)
    Error messagesAd-hoc functionimpl Display#[error("...")]
    From conversionManualimpl From#[from]
    Source chainManual fieldfn source()Auto from #[from]
    BoilerplateMinimal~30 lines~5 lines

    Exercises

  • Manually implement what thiserror would generate for a FileError with NotFound, PermissionDenied, and IoError(#[from] std::io::Error) variants.
  • Compare the generated code from a simple #[derive(thiserror::Error)] usage with the manual implementation in this example.
  • Create a two-level error hierarchy where AppError wraps DbError and IoError, implementing all necessary From conversions manually.
  • Open Source Repos