1003 — Custom Error Types
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
fmt::Display on an error enum with descriptive per-variant messagesstd::error::Error (often just impl std::error::Error for MyError {})Result<T, ValidationError> from validation functionsDisplay, Debug, and Errorexception and type variant approachesCode 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
| Aspect | Rust | OCaml |
|---|---|---|
| Error type | enum ValidationError | type validation_error or exception |
| Display | impl fmt::Display | string_of_validation_error function |
Error trait | impl std::error::Error {} | No equivalent trait |
? operator | Propagates Result | No equivalent (use bind or let*) |
| Context | Struct DetailedError { field, message } | Record or variant with fields |
| Panic | panic!("…") | 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"
);
}
}#[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 typesError trait ecosystemRust Approach
Display and ErrorResult<T, E> in the return type makes fallibility explicitError trait enables interop with Box<dyn Error> and error-handling cratesComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Error declaration | exception Foo of string | enum MyError { Foo(String) } |
| Type visibility | Not in signature | In Result<T, E> return type |
| Pattern matching | try ... with | match result { Ok/Err } |
| Exhaustiveness | No (catch-all needed) | Yes (compiler enforced) |
| Display | Manual string_of_* | impl Display trait |
| Composability | Limited | Error trait + From + ? |
Exercises
from method: ValidationError::from_str(s: &str) -> Option<ValidationError> that parses an error message back to a variant.std::error::Error::source to chain errors: add a ValidationError::ChainedError(Box<dyn Error>) variant.From<ValidationError> for String so .to_string() calls produce the display message.thiserror::Error derive macro to eliminate the Display and Error boilerplate.Validation module that accumulates multiple errors (not just the first) using Result.bind and List.