ExamplesBy LevelBy TopicLearning Paths
1017 Intermediate

1017-typed-errors — Typed Error Hierarchies

Functional Programming

Tutorial

The Problem

As applications grow, different subsystems produce different categories of errors. A web service has authentication errors, database errors, network errors, and business logic errors. Representing all of these as String or Box<dyn Error> loses type information that callers could use to take specific recovery actions — retry on a timeout, redirect on auth failure, or surface a 400 vs 500 HTTP status code.

Typed error enums let callers pattern-match on the error variant, enabling precise handling. The thiserror crate automates the boilerplate, but the underlying pattern is pure Rust trait implementations.

🎯 Learning Outcomes

  • • Design an error enum hierarchy with subsystem-specific variants
  • • Implement Display and std::error::Error for each error type
  • • Use From<SubsystemError> to convert subsystem errors into top-level errors with ?
  • • Understand when typed errors are better than anyhow::Error
  • • Pattern-match on error variants in call sites for specific recovery logic
  • Code Example

    #![allow(clippy::all)]
    // 1017: Typed Error Hierarchy
    // Enum with variants for each subsystem
    
    use std::fmt;
    
    // Subsystem error types
    #[derive(Debug, PartialEq)]
    enum DbError {
        ConnectionFailed,
        QueryFailed(String),
        NotFound(String),
    }
    
    impl fmt::Display for DbError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                DbError::ConnectionFailed => write!(f, "database connection failed"),
                DbError::QueryFailed(q) => write!(f, "query failed: {}", q),
                DbError::NotFound(id) => write!(f, "not found: {}", id),
            }
        }
    }
    impl std::error::Error for DbError {}
    
    #[derive(Debug, PartialEq)]
    enum AuthError {
        InvalidToken,
        Expired,
        Forbidden(String),
    }
    
    impl fmt::Display for AuthError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AuthError::InvalidToken => write!(f, "invalid token"),
                AuthError::Expired => write!(f, "token expired"),
                AuthError::Forbidden(r) => write!(f, "forbidden: {}", r),
            }
        }
    }
    impl std::error::Error for AuthError {}
    
    #[derive(Debug, PartialEq)]
    enum ApiError {
        BadRequest(String),
        RateLimit,
    }
    
    impl fmt::Display for ApiError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ApiError::BadRequest(msg) => write!(f, "bad request: {}", msg),
                ApiError::RateLimit => write!(f, "rate limited"),
            }
        }
    }
    impl std::error::Error for ApiError {}
    
    // Top-level error unifies all subsystems
    #[derive(Debug)]
    enum AppError {
        Db(DbError),
        Auth(AuthError),
        Api(ApiError),
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::Db(e) => write!(f, "[DB] {}", e),
                AppError::Auth(e) => write!(f, "[Auth] {}", e),
                AppError::Api(e) => write!(f, "[API] {}", e),
            }
        }
    }
    impl std::error::Error for AppError {
        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
            match self {
                AppError::Db(e) => Some(e),
                AppError::Auth(e) => Some(e),
                AppError::Api(e) => Some(e),
            }
        }
    }
    
    impl From<DbError> for AppError {
        fn from(e: DbError) -> Self {
            AppError::Db(e)
        }
    }
    impl From<AuthError> for AppError {
        fn from(e: AuthError) -> Self {
            AppError::Auth(e)
        }
    }
    impl From<ApiError> for AppError {
        fn from(e: ApiError) -> Self {
            AppError::Api(e)
        }
    }
    
    // Subsystem functions
    fn db_find_user(id: &str) -> Result<String, DbError> {
        if id == "missing" {
            Err(DbError::NotFound(id.into()))
        } else {
            Ok(format!("user_{}", id))
        }
    }
    
    fn auth_check(token: &str) -> Result<(), AuthError> {
        if token.is_empty() {
            Err(AuthError::InvalidToken)
        } else if token == "expired" {
            Err(AuthError::Expired)
        } else {
            Ok(())
        }
    }
    
    // App layer — ? auto-converts via From
    fn get_user(token: &str, user_id: &str) -> Result<String, AppError> {
        auth_check(token)?; // AuthError -> AppError
        let user = db_find_user(user_id)?; // DbError -> AppError
        Ok(user)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_success() {
            assert_eq!(get_user("valid", "123").unwrap(), "user_123");
        }
    
        #[test]
        fn test_auth_error() {
            let err = get_user("", "123").unwrap_err();
            assert!(matches!(err, AppError::Auth(AuthError::InvalidToken)));
        }
    
        #[test]
        fn test_expired_token() {
            let err = get_user("expired", "123").unwrap_err();
            assert!(matches!(err, AppError::Auth(AuthError::Expired)));
        }
    
        #[test]
        fn test_db_error() {
            let err = get_user("valid", "missing").unwrap_err();
            assert!(matches!(err, AppError::Db(DbError::NotFound(_))));
        }
    
        #[test]
        fn test_display_format() {
            let err = AppError::Db(DbError::QueryFailed("SELECT *".into()));
            assert_eq!(err.to_string(), "[DB] query failed: SELECT *");
    
            let err = AppError::Auth(AuthError::Expired);
            assert_eq!(err.to_string(), "[Auth] token expired");
        }
    
        #[test]
        fn test_error_source() {
            use std::error::Error;
            let err = AppError::Db(DbError::ConnectionFailed);
            let source = err.source().unwrap();
            assert_eq!(source.to_string(), "database connection failed");
        }
    
        #[test]
        fn test_pattern_matching_exhaustive() {
            // The compiler ensures all subsystems are handled
            fn handle(err: AppError) -> &'static str {
                match err {
                    AppError::Db(_) => "database issue",
                    AppError::Auth(_) => "auth issue",
                    AppError::Api(_) => "api issue",
                }
            }
            assert_eq!(handle(AppError::Api(ApiError::RateLimit)), "api issue");
        }
    }

    Key Differences

  • **From trait**: Rust's From<SubsystemError> for AppError enables automatic conversion with ?; OCaml requires explicit variant wrapping.
  • Display vs Show: Rust's Display trait formats errors for human consumption; OCaml typically uses to_string methods or Format.fprintf in a polymorphic variant.
  • Error chaining: Rust's Error::source() method provides a standard way to walk the cause chain; OCaml's Base.Error uses a lazy tree structure.
  • **thiserror automation**: The thiserror crate generates Display, Error, and From impls via #[derive]; OCaml has ppx_sexp_conv for serialisation but no equivalent.
  • OCaml Approach

    OCaml uses polymorphic variants or module-scoped exception types for typed error hierarchies:

    type db_error = ConnectionFailed | QueryFailed of string
    type auth_error = InvalidToken | Expired
    type app_error = Db of db_error | Auth of auth_error
    
    let handle = function
      | Db ConnectionFailed -> retry ()
      | Auth Expired -> refresh_token ()
      | _ -> internal_error ()
    

    Base.Or_error provides Error.t which can be tagged and introspected, but the pattern-matching approach above is more common for typed hierarchies.

    Full Source

    #![allow(clippy::all)]
    // 1017: Typed Error Hierarchy
    // Enum with variants for each subsystem
    
    use std::fmt;
    
    // Subsystem error types
    #[derive(Debug, PartialEq)]
    enum DbError {
        ConnectionFailed,
        QueryFailed(String),
        NotFound(String),
    }
    
    impl fmt::Display for DbError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                DbError::ConnectionFailed => write!(f, "database connection failed"),
                DbError::QueryFailed(q) => write!(f, "query failed: {}", q),
                DbError::NotFound(id) => write!(f, "not found: {}", id),
            }
        }
    }
    impl std::error::Error for DbError {}
    
    #[derive(Debug, PartialEq)]
    enum AuthError {
        InvalidToken,
        Expired,
        Forbidden(String),
    }
    
    impl fmt::Display for AuthError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AuthError::InvalidToken => write!(f, "invalid token"),
                AuthError::Expired => write!(f, "token expired"),
                AuthError::Forbidden(r) => write!(f, "forbidden: {}", r),
            }
        }
    }
    impl std::error::Error for AuthError {}
    
    #[derive(Debug, PartialEq)]
    enum ApiError {
        BadRequest(String),
        RateLimit,
    }
    
    impl fmt::Display for ApiError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ApiError::BadRequest(msg) => write!(f, "bad request: {}", msg),
                ApiError::RateLimit => write!(f, "rate limited"),
            }
        }
    }
    impl std::error::Error for ApiError {}
    
    // Top-level error unifies all subsystems
    #[derive(Debug)]
    enum AppError {
        Db(DbError),
        Auth(AuthError),
        Api(ApiError),
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::Db(e) => write!(f, "[DB] {}", e),
                AppError::Auth(e) => write!(f, "[Auth] {}", e),
                AppError::Api(e) => write!(f, "[API] {}", e),
            }
        }
    }
    impl std::error::Error for AppError {
        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
            match self {
                AppError::Db(e) => Some(e),
                AppError::Auth(e) => Some(e),
                AppError::Api(e) => Some(e),
            }
        }
    }
    
    impl From<DbError> for AppError {
        fn from(e: DbError) -> Self {
            AppError::Db(e)
        }
    }
    impl From<AuthError> for AppError {
        fn from(e: AuthError) -> Self {
            AppError::Auth(e)
        }
    }
    impl From<ApiError> for AppError {
        fn from(e: ApiError) -> Self {
            AppError::Api(e)
        }
    }
    
    // Subsystem functions
    fn db_find_user(id: &str) -> Result<String, DbError> {
        if id == "missing" {
            Err(DbError::NotFound(id.into()))
        } else {
            Ok(format!("user_{}", id))
        }
    }
    
    fn auth_check(token: &str) -> Result<(), AuthError> {
        if token.is_empty() {
            Err(AuthError::InvalidToken)
        } else if token == "expired" {
            Err(AuthError::Expired)
        } else {
            Ok(())
        }
    }
    
    // App layer — ? auto-converts via From
    fn get_user(token: &str, user_id: &str) -> Result<String, AppError> {
        auth_check(token)?; // AuthError -> AppError
        let user = db_find_user(user_id)?; // DbError -> AppError
        Ok(user)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_success() {
            assert_eq!(get_user("valid", "123").unwrap(), "user_123");
        }
    
        #[test]
        fn test_auth_error() {
            let err = get_user("", "123").unwrap_err();
            assert!(matches!(err, AppError::Auth(AuthError::InvalidToken)));
        }
    
        #[test]
        fn test_expired_token() {
            let err = get_user("expired", "123").unwrap_err();
            assert!(matches!(err, AppError::Auth(AuthError::Expired)));
        }
    
        #[test]
        fn test_db_error() {
            let err = get_user("valid", "missing").unwrap_err();
            assert!(matches!(err, AppError::Db(DbError::NotFound(_))));
        }
    
        #[test]
        fn test_display_format() {
            let err = AppError::Db(DbError::QueryFailed("SELECT *".into()));
            assert_eq!(err.to_string(), "[DB] query failed: SELECT *");
    
            let err = AppError::Auth(AuthError::Expired);
            assert_eq!(err.to_string(), "[Auth] token expired");
        }
    
        #[test]
        fn test_error_source() {
            use std::error::Error;
            let err = AppError::Db(DbError::ConnectionFailed);
            let source = err.source().unwrap();
            assert_eq!(source.to_string(), "database connection failed");
        }
    
        #[test]
        fn test_pattern_matching_exhaustive() {
            // The compiler ensures all subsystems are handled
            fn handle(err: AppError) -> &'static str {
                match err {
                    AppError::Db(_) => "database issue",
                    AppError::Auth(_) => "auth issue",
                    AppError::Api(_) => "api issue",
                }
            }
            assert_eq!(handle(AppError::Api(ApiError::RateLimit)), "api issue");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_success() {
            assert_eq!(get_user("valid", "123").unwrap(), "user_123");
        }
    
        #[test]
        fn test_auth_error() {
            let err = get_user("", "123").unwrap_err();
            assert!(matches!(err, AppError::Auth(AuthError::InvalidToken)));
        }
    
        #[test]
        fn test_expired_token() {
            let err = get_user("expired", "123").unwrap_err();
            assert!(matches!(err, AppError::Auth(AuthError::Expired)));
        }
    
        #[test]
        fn test_db_error() {
            let err = get_user("valid", "missing").unwrap_err();
            assert!(matches!(err, AppError::Db(DbError::NotFound(_))));
        }
    
        #[test]
        fn test_display_format() {
            let err = AppError::Db(DbError::QueryFailed("SELECT *".into()));
            assert_eq!(err.to_string(), "[DB] query failed: SELECT *");
    
            let err = AppError::Auth(AuthError::Expired);
            assert_eq!(err.to_string(), "[Auth] token expired");
        }
    
        #[test]
        fn test_error_source() {
            use std::error::Error;
            let err = AppError::Db(DbError::ConnectionFailed);
            let source = err.source().unwrap();
            assert_eq!(source.to_string(), "database connection failed");
        }
    
        #[test]
        fn test_pattern_matching_exhaustive() {
            // The compiler ensures all subsystems are handled
            fn handle(err: AppError) -> &'static str {
                match err {
                    AppError::Db(_) => "database issue",
                    AppError::Auth(_) => "auth issue",
                    AppError::Api(_) => "api issue",
                }
            }
            assert_eq!(handle(AppError::Api(ApiError::RateLimit)), "api issue");
        }
    }

    Deep Comparison

    Typed Error Hierarchy — Comparison

    Core Insight

    Large applications need structured errors. Both languages use nested enums/variants, but Rust's From trait eliminates the manual lifting that OCaml requires.

    OCaml Approach

  • • Nested variant types: type app_error = Db of db_error | Auth of auth_error
  • • Manual lifting at each boundary: Error (Db e) / Error (Auth e)
  • • Individual string_of_* functions for display
  • • No standard trait for error composition
  • Rust Approach

  • • Same enum nesting pattern: enum AppError { Db(DbError), Auth(AuthError) }
  • From impls automate lifting via ?
  • Display + Error traits provide standard formatting
  • source() method chains subsystem errors
  • Comparison Table

    AspectOCamlRust
    HierarchyNested variantsNested enums
    LiftingManual Error (Db e)Automatic via From + ?
    Displaystring_of_* functionsimpl Display
    ExhaustivenessYes (match)Yes (match)
    Source chainManualError::source()
    BoilerplateMedium (lifting)Medium (From impls)

    Exercises

  • Add a ValidationError(String) variant to AppError and a mock function that returns it. Pattern-match on it in a handler that returns a 400 status code string.
  • Implement Error::source() for AppError so each variant returns its inner error as the cause.
  • Refactor the example to use thiserror::Error derive macro and verify the generated code matches the manual implementation.
  • Open Source Repos