ExamplesBy LevelBy TopicLearning Paths
050 Intermediate GitHub

050 — Custom Error Types

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "050 — Custom Error Types" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Custom error types make error handling self-documenting. Key difference from OCaml: 1. **Trait implementation**: Rust requires implementing `Display` and `Error` traits to integrate with the ecosystem. OCaml errors are plain algebraic types — no trait/interface needed, but also no standard display mechanism.

Tutorial

The Problem

Custom error types make error handling self-documenting. Instead of returning String errors (which lose structure) or Box<dyn Error> (which loses type safety), a custom enum provides exhaustive matching, compiler-checked error handling, and rich error messages. This is how Rust's standard library and major crates (serde, tokio, axum) define their errors.

The std::error::Error trait requires implementing Display and optionally source() (for error chaining). The Display implementation provides human-readable messages. Combined with From conversions (example 049), custom error types are the foundation of production Rust error handling.

🎯 Learning Outcomes

  • • Define a custom error enum with multiple variants carrying context data
  • • Implement std::fmt::Display for human-readable error messages
  • • Implement std::error::Error to integrate with the Rust ecosystem
  • • Use custom errors with ? and map_err in real functions
  • • Understand the source() method for error chaining (wrapping underlying causes)
  • • Define a custom error enum with one variant per failure mode and implement fmt::Display for human-readable messages
  • • Implement std::error::Error and optionally source() for error chaining with the underlying cause
  • Code Example

    #![allow(clippy::all)]
    // A custom error enum with Display + std::error::Error for self-documenting failures.
    use std::fmt;
    
    #[derive(Debug, PartialEq)]
    pub 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 {}
    
    pub 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)
        }
    }
    
    pub fn validate_name(name: &str) -> Result<&str, ValidationError> {
        if name.is_empty() {
            Err(ValidationError::EmptyName)
        } else {
            Ok(name)
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_validate_age_ok() {
            assert_eq!(validate_age(30), Ok(30));
        }
    
        #[test]
        fn test_validate_age_negative() {
            assert_eq!(validate_age(-1), Err(ValidationError::NegativeAge(-1)));
        }
    
        #[test]
        fn test_validate_age_unreasonable() {
            assert_eq!(validate_age(200), Err(ValidationError::UnreasonableAge(200)));
        }
    
        #[test]
        fn test_validate_name() {
            assert_eq!(validate_name("Ada"), Ok("Ada"));
            assert_eq!(validate_name(""), Err(ValidationError::EmptyName));
        }
    
        #[test]
        fn test_display_messages() {
            assert_eq!(ValidationError::NegativeAge(-5).to_string(), "negative age: -5");
            assert_eq!(ValidationError::UnreasonableAge(200).to_string(), "unreasonable age: 200");
            assert_eq!(ValidationError::EmptyName.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_is_a_std_error() {
            let e: Box<dyn std::error::Error> = Box::new(ValidationError::EmptyName);
            assert_eq!(e.to_string(), "name cannot be empty");
        }
    }

    Key Differences

  • Trait implementation: Rust requires implementing Display and Error traits to integrate with the ecosystem. OCaml errors are plain algebraic types — no trait/interface needed, but also no standard display mechanism.
  • Error chaining: Rust's Error::source() returns the underlying cause. OCaml achieves this by embedding the cause in the error variant: type error = IoFailed of exn | ....
  • Derive macros: With thiserror::derive(Error), Rust can derive Display and Error from attribute annotations. OCaml has no standard equivalent; writing to_string functions manually is typical.
  • Pattern matching: Both languages allow exhaustive matching on error variants. Rust's match is checked at compile time — adding a new variant requires handling it everywhere it is matched.
  • **enum for error variants:** A custom error type is an enum — each variant represents a different failure mode. This allows callers to pattern-match on specific errors and handle them differently.
  • **Display for human-readable messages:** Implementing fmt::Display for the error type provides the human-readable message shown in logs and user interfaces. Debug is for developers; Display is for users.
  • **Error trait:** Implementing std::error::Error marks the type as an error type. It requires Debug + Display. The source() method provides error chaining — linking to the underlying cause.
  • OCaml custom errors: OCaml uses variant types: type error = InvalidInput | NetworkError of string | ParseError of int * string. Pattern matching handles each case. OCaml doesn't have a standard Error trait — error handling style varies by library.
  • OCaml Approach

    OCaml's equivalent: define type validation_error = NegativeAge of int | UnreasonableAge of int | EmptyName. The Display equivalent is a to_string function: let error_to_string = function NegativeAge n -> Printf.sprintf "negative age: %d" n | .... OCaml has no Error trait — errors are just values. The Printexc module handles exception pretty-printing separately.

    Full Source

    #![allow(clippy::all)]
    // A custom error enum with Display + std::error::Error for self-documenting failures.
    use std::fmt;
    
    #[derive(Debug, PartialEq)]
    pub 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 {}
    
    pub 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)
        }
    }
    
    pub fn validate_name(name: &str) -> Result<&str, ValidationError> {
        if name.is_empty() {
            Err(ValidationError::EmptyName)
        } else {
            Ok(name)
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_validate_age_ok() {
            assert_eq!(validate_age(30), Ok(30));
        }
    
        #[test]
        fn test_validate_age_negative() {
            assert_eq!(validate_age(-1), Err(ValidationError::NegativeAge(-1)));
        }
    
        #[test]
        fn test_validate_age_unreasonable() {
            assert_eq!(validate_age(200), Err(ValidationError::UnreasonableAge(200)));
        }
    
        #[test]
        fn test_validate_name() {
            assert_eq!(validate_name("Ada"), Ok("Ada"));
            assert_eq!(validate_name(""), Err(ValidationError::EmptyName));
        }
    
        #[test]
        fn test_display_messages() {
            assert_eq!(ValidationError::NegativeAge(-5).to_string(), "negative age: -5");
            assert_eq!(ValidationError::UnreasonableAge(200).to_string(), "unreasonable age: 200");
            assert_eq!(ValidationError::EmptyName.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_is_a_std_error() {
            let e: Box<dyn std::error::Error> = Box::new(ValidationError::EmptyName);
            assert_eq!(e.to_string(), "name cannot be empty");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_validate_age_ok() {
            assert_eq!(validate_age(30), Ok(30));
        }
    
        #[test]
        fn test_validate_age_negative() {
            assert_eq!(validate_age(-1), Err(ValidationError::NegativeAge(-1)));
        }
    
        #[test]
        fn test_validate_age_unreasonable() {
            assert_eq!(validate_age(200), Err(ValidationError::UnreasonableAge(200)));
        }
    
        #[test]
        fn test_validate_name() {
            assert_eq!(validate_name("Ada"), Ok("Ada"));
            assert_eq!(validate_name(""), Err(ValidationError::EmptyName));
        }
    
        #[test]
        fn test_display_messages() {
            assert_eq!(ValidationError::NegativeAge(-5).to_string(), "negative age: -5");
            assert_eq!(ValidationError::UnreasonableAge(200).to_string(), "unreasonable age: 200");
            assert_eq!(ValidationError::EmptyName.to_string(), "name cannot be empty");
        }
    
        #[test]
        fn test_is_a_std_error() {
            let e: Box<dyn std::error::Error> = Box::new(ValidationError::EmptyName);
            assert_eq!(e.to_string(), "name cannot be empty");
        }
    }

    Deep Comparison

    OCaml vs Rust: Custom Error Types

    Overview

    See the example.rs and example.ml files for detailed implementations.

    Key Differences

    AspectOCamlRust
    Type systemHindley-MilnerOwnership + traits
    MemoryGCZero-cost abstractions
    MutabilityExplicit refmut keyword
    Error handlingOption/ResultResult<T, E>

    See README.md for detailed comparison.

    Exercises

  • Error source chain: Add a NetworkError { message: String, cause: Box<dyn std::error::Error> } variant to your error type and implement source() to return Some(cause.as_ref()). Write a function that wraps an underlying error.
  • Display with context: Write error messages that include both the bad value and what was expected: "expected age 0-150, got 200". Use write!(f, ...) with multiple fields from the variant.
  • Error catalogue: Define a complete ApiError type for a web API with variants for NotFound { resource: String }, Unauthorized, RateLimit { retry_after_secs: u64 }, and Internal(Box<dyn std::error::Error>). Implement Display, Error, and HTTP status code mapping.
  • Error hierarchy: Implement a three-level error hierarchy: DbError, ApiError (which wraps DbError), and AppError (which wraps ApiError). Implement From conversions so ? works at each level.
  • Error reporting: Add a fn suggestions(&self) -> Vec<String> method to your custom error type that returns actionable suggestions for each error variant — e.g., for InvalidInput suggest checking the input format.
  • Open Source Repos