ExamplesBy LevelBy TopicLearning Paths
1003 Intermediate

1003 — Custom Error Types

Functional Programming

Tutorial

The Problem

Define custom error enums and implement fmt::Display and std::error::Error for them. Create ValidationError for age and name validation, and DetailedError for field-level errors with context. Compare with OCaml's exception-based error handling and variant-based Result errors.

🎯 Learning Outcomes

  • • Implement fmt::Display on an error enum with descriptive per-variant messages
  • • Implement std::error::Error (often just impl std::error::Error for MyError {})
  • • Return Result<T, ValidationError> from validation functions
  • • Understand the relationship between Display, Debug, and Error
  • • Map Rust's enum-based errors to OCaml's exception and type variant approaches
  • • Recognise when to use a simple enum vs a struct error with context fields
  • Code Example

    #![allow(clippy::all)]
    // 1003: Custom Error Types
    // Custom error type with Display + Error impl
    
    use std::fmt;
    
    // Approach 1: Simple error enum with Display
    #[derive(Debug, PartialEq)]
    enum ValidationError {
        NegativeAge(i32),
        UnreasonableAge(i32),
        EmptyName,
    }
    
    impl fmt::Display for ValidationError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ValidationError::NegativeAge(n) => write!(f, "negative age: {}", n),
                ValidationError::UnreasonableAge(n) => write!(f, "unreasonable age: {}", n),
                ValidationError::EmptyName => write!(f, "name cannot be empty"),
            }
        }
    }
    
    impl std::error::Error for ValidationError {}
    
    fn validate_age(age: i32) -> Result<i32, ValidationError> {
        if age < 0 {
            Err(ValidationError::NegativeAge(age))
        } else if age > 150 {
            Err(ValidationError::UnreasonableAge(age))
        } else {
            Ok(age)
        }
    }
    
    fn validate_name(name: &str) -> Result<&str, ValidationError> {
        if name.is_empty() {
            Err(ValidationError::EmptyName)
        } else {
            Ok(name)
        }
    }
    
    // Approach 2: Error with structured context
    #[derive(Debug)]
    struct DetailedError {
        field: String,
        message: String,
    }
    
    impl fmt::Display for DetailedError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "field '{}': {}", self.field, self.message)
        }
    }
    
    impl std::error::Error for DetailedError {}
    
    fn validate_field(field: &str, value: &str) -> Result<(), DetailedError> {
        if value.is_empty() {
            Err(DetailedError {
                field: field.to_string(),
                message: "cannot be empty".to_string(),
            })
        } else {
            Ok(())
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_age() {
            assert_eq!(validate_age(25), Ok(25));
        }
    
        #[test]
        fn test_negative_age() {
            assert_eq!(validate_age(-5), Err(ValidationError::NegativeAge(-5)));
        }
    
        #[test]
        fn test_unreasonable_age() {
            assert_eq!(
                validate_age(200),
                Err(ValidationError::UnreasonableAge(200))
            );
        }
    
        #[test]
        fn test_display_impl() {
            let err = ValidationError::NegativeAge(-1);
            assert_eq!(err.to_string(), "negative age: -1");
    
            let err = ValidationError::EmptyName;
            assert_eq!(err.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_error_trait() {
            let err: Box<dyn std::error::Error> = Box::new(ValidationError::EmptyName);
            assert_eq!(err.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_validate_name() {
            assert_eq!(validate_name("Alice"), Ok("Alice"));
            assert_eq!(validate_name(""), Err(ValidationError::EmptyName));
        }
    
        #[test]
        fn test_detailed_error() {
            let result = validate_field("email", "");
            assert!(result.is_err());
            assert_eq!(
                result.unwrap_err().to_string(),
                "field 'email': cannot be empty"
            );
        }
    }

    Key Differences

    AspectRustOCaml
    Error typeenum ValidationErrortype validation_error or exception
    Displayimpl fmt::Displaystring_of_validation_error function
    Error traitimpl std::error::Error {}No equivalent trait
    ? operatorPropagates ResultNo equivalent (use bind or let*)
    ContextStruct DetailedError { field, message }Record or variant with fields
    Panicpanic!("…")failwith "…" / assert false

    The Display + Error combination is the Rust ecosystem contract for error types. Libraries like anyhow and thiserror build on this foundation — thiserror derives Display from format strings, eliminating the boilerplate.

    OCaml Approach

    OCaml offers two approaches: exception Invalid_age of string for traditional exception-based flow, and type validation_error = NegativeAge of int | UnreasonableAge of int | EmptyName for Result-based flow. Both are idiomatic. The exception approach uses raise/try … with syntax. The Result approach mirrors Rust exactly. OCaml's type inference makes Result functions more concise since the error type is inferred.

    Full Source

    #![allow(clippy::all)]
    // 1003: Custom Error Types
    // Custom error type with Display + Error impl
    
    use std::fmt;
    
    // Approach 1: Simple error enum with Display
    #[derive(Debug, PartialEq)]
    enum ValidationError {
        NegativeAge(i32),
        UnreasonableAge(i32),
        EmptyName,
    }
    
    impl fmt::Display for ValidationError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ValidationError::NegativeAge(n) => write!(f, "negative age: {}", n),
                ValidationError::UnreasonableAge(n) => write!(f, "unreasonable age: {}", n),
                ValidationError::EmptyName => write!(f, "name cannot be empty"),
            }
        }
    }
    
    impl std::error::Error for ValidationError {}
    
    fn validate_age(age: i32) -> Result<i32, ValidationError> {
        if age < 0 {
            Err(ValidationError::NegativeAge(age))
        } else if age > 150 {
            Err(ValidationError::UnreasonableAge(age))
        } else {
            Ok(age)
        }
    }
    
    fn validate_name(name: &str) -> Result<&str, ValidationError> {
        if name.is_empty() {
            Err(ValidationError::EmptyName)
        } else {
            Ok(name)
        }
    }
    
    // Approach 2: Error with structured context
    #[derive(Debug)]
    struct DetailedError {
        field: String,
        message: String,
    }
    
    impl fmt::Display for DetailedError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "field '{}': {}", self.field, self.message)
        }
    }
    
    impl std::error::Error for DetailedError {}
    
    fn validate_field(field: &str, value: &str) -> Result<(), DetailedError> {
        if value.is_empty() {
            Err(DetailedError {
                field: field.to_string(),
                message: "cannot be empty".to_string(),
            })
        } else {
            Ok(())
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_age() {
            assert_eq!(validate_age(25), Ok(25));
        }
    
        #[test]
        fn test_negative_age() {
            assert_eq!(validate_age(-5), Err(ValidationError::NegativeAge(-5)));
        }
    
        #[test]
        fn test_unreasonable_age() {
            assert_eq!(
                validate_age(200),
                Err(ValidationError::UnreasonableAge(200))
            );
        }
    
        #[test]
        fn test_display_impl() {
            let err = ValidationError::NegativeAge(-1);
            assert_eq!(err.to_string(), "negative age: -1");
    
            let err = ValidationError::EmptyName;
            assert_eq!(err.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_error_trait() {
            let err: Box<dyn std::error::Error> = Box::new(ValidationError::EmptyName);
            assert_eq!(err.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_validate_name() {
            assert_eq!(validate_name("Alice"), Ok("Alice"));
            assert_eq!(validate_name(""), Err(ValidationError::EmptyName));
        }
    
        #[test]
        fn test_detailed_error() {
            let result = validate_field("email", "");
            assert!(result.is_err());
            assert_eq!(
                result.unwrap_err().to_string(),
                "field 'email': cannot be empty"
            );
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_age() {
            assert_eq!(validate_age(25), Ok(25));
        }
    
        #[test]
        fn test_negative_age() {
            assert_eq!(validate_age(-5), Err(ValidationError::NegativeAge(-5)));
        }
    
        #[test]
        fn test_unreasonable_age() {
            assert_eq!(
                validate_age(200),
                Err(ValidationError::UnreasonableAge(200))
            );
        }
    
        #[test]
        fn test_display_impl() {
            let err = ValidationError::NegativeAge(-1);
            assert_eq!(err.to_string(), "negative age: -1");
    
            let err = ValidationError::EmptyName;
            assert_eq!(err.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_error_trait() {
            let err: Box<dyn std::error::Error> = Box::new(ValidationError::EmptyName);
            assert_eq!(err.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_validate_name() {
            assert_eq!(validate_name("Alice"), Ok("Alice"));
            assert_eq!(validate_name(""), Err(ValidationError::EmptyName));
        }
    
        #[test]
        fn test_detailed_error() {
            let result = validate_field("email", "");
            assert!(result.is_err());
            assert_eq!(
                result.unwrap_err().to_string(),
                "field 'email': cannot be empty"
            );
        }
    }

    Deep Comparison

    Custom Error Types — Comparison

    Core Insight

    OCaml exceptions are dynamic and bypass the type checker; Rust errors are typed enums that the compiler tracks through Result<T, E>.

    OCaml Approach

  • exception declarations create runtime-only error types
  • • Exceptions don't appear in function signatures
  • • Callers have no compile-time indication a function can fail
  • • Polymorphic variants offer a typed alternative but lack the Error trait ecosystem
  • Rust Approach

  • • Error types are regular enums implementing Display and Error
  • Result<T, E> in the return type makes fallibility explicit
  • • Pattern matching on error variants is exhaustive
  • • The Error trait enables interop with Box<dyn Error> and error-handling crates
  • Comparison Table

    AspectOCamlRust
    Error declarationexception Foo of stringenum MyError { Foo(String) }
    Type visibilityNot in signatureIn Result<T, E> return type
    Pattern matchingtry ... withmatch result { Ok/Err }
    ExhaustivenessNo (catch-all needed)Yes (compiler enforced)
    DisplayManual string_of_*impl Display trait
    ComposabilityLimitedError trait + From + ?

    Exercises

  • Add a from method: ValidationError::from_str(s: &str) -> Option<ValidationError> that parses an error message back to a variant.
  • Implement std::error::Error::source to chain errors: add a ValidationError::ChainedError(Box<dyn Error>) variant.
  • Implement From<ValidationError> for String so .to_string() calls produce the display message.
  • Use thiserror::Error derive macro to eliminate the Display and Error boilerplate.
  • In OCaml, implement a Validation module that accumulates multiple errors (not just the first) using Result.bind and List.
  • Open Source Repos