1017-typed-errors — Typed Error Hierarchies
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
Display and std::error::Error for each error typeFrom<SubsystemError> to convert subsystem errors into top-level errors with ?anyhow::ErrorCode 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 trait formats errors for human consumption; OCaml typically uses to_string methods or Format.fprintf in a polymorphic variant.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");
}
}#[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
type app_error = Db of db_error | Auth of auth_errorError (Db e) / Error (Auth e)string_of_* functions for displayRust Approach
enum AppError { Db(DbError), Auth(AuthError) }From impls automate lifting via ?Display + Error traits provide standard formattingsource() method chains subsystem errorsComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Hierarchy | Nested variants | Nested enums |
| Lifting | Manual Error (Db e) | Automatic via From + ? |
| Display | string_of_* functions | impl Display |
| Exhaustiveness | Yes (match) | Yes (match) |
| Source chain | Manual | Error::source() |
| Boilerplate | Medium (lifting) | Medium (From impls) |
Exercises
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.Error::source() for AppError so each variant returns its inner error as the cause.thiserror::Error derive macro and verify the generated code matches the manual implementation.