ExamplesBy LevelBy TopicLearning Paths
1018 Intermediate

1018-error-downcast — Error Downcast

Functional Programming

Tutorial

The Problem

When errors are type-erased as Box<dyn Error> or Arc<dyn Error>, you lose the ability to pattern-match on specific error types. This is the trade-off of dynamic dispatch: flexibility at the cost of type information. Downcasting recovers the concrete type at runtime, using the Any mechanism under the hood.

This pattern appears wherever error types cross API boundaries: plugin systems, dynamic library interfaces, and functions returning Box<dyn Error> for flexibility. The downcast_ref and downcast methods are Rust's equivalent of Java's instanceof check plus cast.

🎯 Learning Outcomes

  • • Understand how Box<dyn Error> erases the concrete error type
  • • Use downcast_ref::<ConcreteError>() to recover a reference to the concrete type
  • • Use downcast::<ConcreteError>() to take ownership of the concrete type from a Box
  • • Walk the Error::source() chain to find errors nested inside wrappers
  • • Know the limitations: downcasting requires 'static bounds on the error type
  • Code Example

    #![allow(clippy::all)]
    // 1018: Error Downcast
    // Downcasting Box<dyn Error> to concrete type
    
    use std::error::Error;
    use std::fmt;
    
    #[derive(Debug)]
    struct DatabaseError(String);
    
    impl fmt::Display for DatabaseError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "database error: {}", self.0)
        }
    }
    impl Error for DatabaseError {}
    
    #[derive(Debug)]
    struct AuthError(String);
    
    impl fmt::Display for AuthError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "auth error: {}", self.0)
        }
    }
    impl Error for AuthError {}
    
    #[derive(Debug)]
    struct NetworkError(String);
    
    impl fmt::Display for NetworkError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "network error: {}", self.0)
        }
    }
    impl Error for NetworkError {}
    
    // Functions returning type-erased errors
    fn might_fail_db() -> Result<(), Box<dyn Error>> {
        Err(Box::new(DatabaseError("timeout".into())))
    }
    
    fn might_fail_auth() -> Result<(), Box<dyn Error>> {
        Err(Box::new(AuthError("expired token".into())))
    }
    
    // Approach 1: downcast_ref — borrow the concrete type
    fn classify_error(err: &(dyn Error + 'static)) -> &'static str {
        if err.downcast_ref::<DatabaseError>().is_some() {
            "database"
        } else if err.downcast_ref::<AuthError>().is_some() {
            "auth"
        } else if err.downcast_ref::<NetworkError>().is_some() {
            "network"
        } else {
            "unknown"
        }
    }
    
    // Approach 2: downcast — take ownership of concrete type
    fn handle_error(err: Box<dyn Error>) -> String {
        if let Ok(db_err) = err.downcast::<DatabaseError>() {
            format!("Handling DB: {}", db_err.0)
        } else {
            "unhandled error".into()
        }
    }
    
    // Approach 3: Type ID check
    fn is_database_error(err: &(dyn Error + 'static)) -> bool {
        err.downcast_ref::<DatabaseError>().is_some()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_downcast_ref_db() {
            let err: Box<dyn Error> = Box::new(DatabaseError("test".into()));
            assert_eq!(classify_error(err.as_ref()), "database");
    
            let concrete = err.downcast_ref::<DatabaseError>().unwrap();
            assert_eq!(concrete.0, "test");
        }
    
        #[test]
        fn test_downcast_ref_auth() {
            let err: Box<dyn Error> = Box::new(AuthError("bad".into()));
            assert_eq!(classify_error(err.as_ref()), "auth");
        }
    
        #[test]
        fn test_downcast_ref_unknown() {
            let err: Box<dyn Error> = Box::new(std::io::Error::new(std::io::ErrorKind::Other, "misc"));
            assert_eq!(classify_error(err.as_ref()), "unknown");
        }
    
        #[test]
        fn test_downcast_owned() {
            let err: Box<dyn Error> = Box::new(DatabaseError("owned".into()));
            let result = handle_error(err);
            assert_eq!(result, "Handling DB: owned");
        }
    
        #[test]
        fn test_downcast_owned_wrong_type() {
            let err: Box<dyn Error> = Box::new(AuthError("nope".into()));
            let result = handle_error(err);
            assert_eq!(result, "unhandled error");
        }
    
        #[test]
        fn test_is_check() {
            let err: Box<dyn Error> = Box::new(DatabaseError("x".into()));
            assert!(is_database_error(err.as_ref()));
    
            let err: Box<dyn Error> = Box::new(AuthError("x".into()));
            assert!(!is_database_error(err.as_ref()));
        }
    
        #[test]
        fn test_from_result() {
            let result = might_fail_db();
            let err = result.unwrap_err();
            assert!(err.downcast_ref::<DatabaseError>().is_some());
        }
    }

    Key Differences

  • Type erasure: Rust explicitly erases types with dyn Trait; OCaml exceptions are always fully typed and matchable.
  • Runtime overhead: downcast_ref performs a single type-ID comparison (essentially free); OCaml match compilation is similar in cost.
  • **'static bound**: Rust downcasting requires the error type to be 'static; OCaml has no equivalent restriction.
  • Safety: Rust downcasting cannot produce unsound code — a failed downcast returns None/Err; OCaml pattern matching is always exhaustive.
  • OCaml Approach

    OCaml exceptions carry typed payloads and can be matched directly without downcasting:

    exception Database_error of string
    exception Auth_error of string
    
    let classify exn =
      match exn with
      | Database_error msg -> "database: " ^ msg
      | Auth_error msg -> "auth: " ^ msg
      | _ -> "unknown"
    

    When using Or_error, the Error.to_exn and Error.of_exn functions bridge between exceptions and the Error.t type. There is no downcasting because exceptions are always typed at the match site.

    Full Source

    #![allow(clippy::all)]
    // 1018: Error Downcast
    // Downcasting Box<dyn Error> to concrete type
    
    use std::error::Error;
    use std::fmt;
    
    #[derive(Debug)]
    struct DatabaseError(String);
    
    impl fmt::Display for DatabaseError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "database error: {}", self.0)
        }
    }
    impl Error for DatabaseError {}
    
    #[derive(Debug)]
    struct AuthError(String);
    
    impl fmt::Display for AuthError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "auth error: {}", self.0)
        }
    }
    impl Error for AuthError {}
    
    #[derive(Debug)]
    struct NetworkError(String);
    
    impl fmt::Display for NetworkError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "network error: {}", self.0)
        }
    }
    impl Error for NetworkError {}
    
    // Functions returning type-erased errors
    fn might_fail_db() -> Result<(), Box<dyn Error>> {
        Err(Box::new(DatabaseError("timeout".into())))
    }
    
    fn might_fail_auth() -> Result<(), Box<dyn Error>> {
        Err(Box::new(AuthError("expired token".into())))
    }
    
    // Approach 1: downcast_ref — borrow the concrete type
    fn classify_error(err: &(dyn Error + 'static)) -> &'static str {
        if err.downcast_ref::<DatabaseError>().is_some() {
            "database"
        } else if err.downcast_ref::<AuthError>().is_some() {
            "auth"
        } else if err.downcast_ref::<NetworkError>().is_some() {
            "network"
        } else {
            "unknown"
        }
    }
    
    // Approach 2: downcast — take ownership of concrete type
    fn handle_error(err: Box<dyn Error>) -> String {
        if let Ok(db_err) = err.downcast::<DatabaseError>() {
            format!("Handling DB: {}", db_err.0)
        } else {
            "unhandled error".into()
        }
    }
    
    // Approach 3: Type ID check
    fn is_database_error(err: &(dyn Error + 'static)) -> bool {
        err.downcast_ref::<DatabaseError>().is_some()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_downcast_ref_db() {
            let err: Box<dyn Error> = Box::new(DatabaseError("test".into()));
            assert_eq!(classify_error(err.as_ref()), "database");
    
            let concrete = err.downcast_ref::<DatabaseError>().unwrap();
            assert_eq!(concrete.0, "test");
        }
    
        #[test]
        fn test_downcast_ref_auth() {
            let err: Box<dyn Error> = Box::new(AuthError("bad".into()));
            assert_eq!(classify_error(err.as_ref()), "auth");
        }
    
        #[test]
        fn test_downcast_ref_unknown() {
            let err: Box<dyn Error> = Box::new(std::io::Error::new(std::io::ErrorKind::Other, "misc"));
            assert_eq!(classify_error(err.as_ref()), "unknown");
        }
    
        #[test]
        fn test_downcast_owned() {
            let err: Box<dyn Error> = Box::new(DatabaseError("owned".into()));
            let result = handle_error(err);
            assert_eq!(result, "Handling DB: owned");
        }
    
        #[test]
        fn test_downcast_owned_wrong_type() {
            let err: Box<dyn Error> = Box::new(AuthError("nope".into()));
            let result = handle_error(err);
            assert_eq!(result, "unhandled error");
        }
    
        #[test]
        fn test_is_check() {
            let err: Box<dyn Error> = Box::new(DatabaseError("x".into()));
            assert!(is_database_error(err.as_ref()));
    
            let err: Box<dyn Error> = Box::new(AuthError("x".into()));
            assert!(!is_database_error(err.as_ref()));
        }
    
        #[test]
        fn test_from_result() {
            let result = might_fail_db();
            let err = result.unwrap_err();
            assert!(err.downcast_ref::<DatabaseError>().is_some());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_downcast_ref_db() {
            let err: Box<dyn Error> = Box::new(DatabaseError("test".into()));
            assert_eq!(classify_error(err.as_ref()), "database");
    
            let concrete = err.downcast_ref::<DatabaseError>().unwrap();
            assert_eq!(concrete.0, "test");
        }
    
        #[test]
        fn test_downcast_ref_auth() {
            let err: Box<dyn Error> = Box::new(AuthError("bad".into()));
            assert_eq!(classify_error(err.as_ref()), "auth");
        }
    
        #[test]
        fn test_downcast_ref_unknown() {
            let err: Box<dyn Error> = Box::new(std::io::Error::new(std::io::ErrorKind::Other, "misc"));
            assert_eq!(classify_error(err.as_ref()), "unknown");
        }
    
        #[test]
        fn test_downcast_owned() {
            let err: Box<dyn Error> = Box::new(DatabaseError("owned".into()));
            let result = handle_error(err);
            assert_eq!(result, "Handling DB: owned");
        }
    
        #[test]
        fn test_downcast_owned_wrong_type() {
            let err: Box<dyn Error> = Box::new(AuthError("nope".into()));
            let result = handle_error(err);
            assert_eq!(result, "unhandled error");
        }
    
        #[test]
        fn test_is_check() {
            let err: Box<dyn Error> = Box::new(DatabaseError("x".into()));
            assert!(is_database_error(err.as_ref()));
    
            let err: Box<dyn Error> = Box::new(AuthError("x".into()));
            assert!(!is_database_error(err.as_ref()));
        }
    
        #[test]
        fn test_from_result() {
            let result = might_fail_db();
            let err = result.unwrap_err();
            assert!(err.downcast_ref::<DatabaseError>().is_some());
        }
    }

    Deep Comparison

    Error Downcast — Comparison

    Core Insight

    Type erasure (Box<dyn Error>) is convenient but loses type information. Downcasting recovers it at runtime — OCaml's exception matching does this naturally, while Rust needs explicit downcasts.

    OCaml Approach

  • • Exceptions are pattern-matchable by default — no "downcast" needed
  • • Extensible variant types (type t += ...) support open matching
  • • GADTs can encode typed error containers
  • • Pattern matching is exhaustive (or has wildcard)
  • Rust Approach

  • downcast_ref::<T>() — borrow as concrete type (returns Option)
  • downcast::<T>() — take ownership (returns Result)
  • • Uses TypeId internally (runtime reflection)
  • • Unavoidable when working with Box<dyn Error> from libraries
  • Comparison Table

    AspectOCamlRust
    Type recoveryPattern matchingdowncast_ref / downcast
    Compile-time safeYes (match)No (runtime check)
    CostZeroTypeId comparison
    OwnershipN/Adowncast consumes Box
    Preferred approachExceptions / variantsTyped enum (avoid downcast)

    Exercises

  • Write a function that walks the Error::source() chain recursively and returns a Vec<&str> of all error messages from root to leaf.
  • Implement a try_recover<E: Error + 'static>(err: Box<dyn Error>) -> Result<(), E> generic function that downcasts and returns the specific error type if it matches.
  • Add a WrappedError struct that wraps another Box<dyn Error> and implements Error::source(). Show that downcasting into the inner error still works via source-chain walking.
  • Open Source Repos