311: Handling Multiple Error Types
Tutorial Video
Text description (accessibility)
This video demonstrates the "311: Handling Multiple Error Types" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Real functions call multiple operations that each have different error types: parsing, I/O, database queries. Key difference from OCaml: 1. **Nominal vs structural**: Rust uses nominal enum variants (closed); OCaml's polymorphic variants are structural (open) — you can add new variants without changing the union type.
Tutorial
The Problem
Real functions call multiple operations that each have different error types: parsing, I/O, database queries. Returning Result<T, String> loses precision; returning a massive union type is unwieldy. The standard approach is a custom error enum with one variant per error source, and impl From<SourceError> for each — enabling ? to automatically wrap errors at each call site. This is the pattern thiserror automates.
🎯 Learning Outcomes
From<E> for each error source to enable ? conversion? operator with multiple error types in a single function bodyCode Example
#![allow(clippy::all)]
//! # Handling Multiple Error Types
//!
//! Unify multiple error types with an enum + `impl From` for each variant.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub struct IoError(pub String);
#[derive(Debug)]
pub struct DbError(pub String);
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "IO: {}", self.0)
}
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "DB: {}", self.0)
}
}
impl std::error::Error for IoError {}
impl std::error::Error for DbError {}
#[derive(Debug)]
pub enum AppError {
Io(IoError),
Db(DbError),
Parse(ParseIntError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO error: {}", e),
AppError::Db(e) => write!(f, "DB error: {}", e),
AppError::Parse(e) => write!(f, "parse error: {}", e),
}
}
}
impl From<IoError> for AppError {
fn from(e: IoError) -> Self {
AppError::Io(e)
}
}
impl From<DbError> for AppError {
fn from(e: DbError) -> Self {
AppError::Db(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
pub fn read_file(path: &str) -> Result<String, IoError> {
if path == "missing" {
Err(IoError(format!("{}: not found", path)))
} else {
Ok("42".to_string())
}
}
pub fn query_db(n: i32) -> Result<Vec<i32>, DbError> {
if n < 0 {
Err(DbError("negative input".to_string()))
} else {
Ok(vec![n, n * 2, n * 3])
}
}
pub fn pipeline(path: &str) -> Result<Vec<i32>, AppError> {
let content = read_file(path)?;
let n: i32 = content.trim().parse()?;
let rows = query_db(n)?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_ok() {
assert!(pipeline("data.txt").is_ok());
}
#[test]
fn test_pipeline_io_err() {
assert!(matches!(pipeline("missing"), Err(AppError::Io(_))));
}
#[test]
fn test_from_io_error() {
let e: AppError = IoError("test".to_string()).into();
assert!(matches!(e, AppError::Io(_)));
}
#[test]
fn test_from_db_error() {
let e: AppError = DbError("test".to_string()).into();
assert!(matches!(e, AppError::Db(_)));
}
}Key Differences
? calls From::from() automatically; OCaml requires explicit Result.map_error at each site.#[derive(thiserror::Error)] with #[from] attributes generates all the From impls, making the manual boilerplate optional.OCaml Approach
OCaml uses polymorphic variants for extensible error types, allowing different error families to be mixed without a common super-enum:
type app_error = [`Parse of string | `Io of string | `Db of string]
let process s : (unit, app_error) result =
let* n = int_of_string_opt s |> Option.to_result ~none:(`Parse "not a number") in
let* () = read_file () |> Result.map_error (fun e -> `Io e) in
write_db n |> Result.map_error (fun e -> `Db e)
Full Source
#![allow(clippy::all)]
//! # Handling Multiple Error Types
//!
//! Unify multiple error types with an enum + `impl From` for each variant.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub struct IoError(pub String);
#[derive(Debug)]
pub struct DbError(pub String);
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "IO: {}", self.0)
}
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "DB: {}", self.0)
}
}
impl std::error::Error for IoError {}
impl std::error::Error for DbError {}
#[derive(Debug)]
pub enum AppError {
Io(IoError),
Db(DbError),
Parse(ParseIntError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO error: {}", e),
AppError::Db(e) => write!(f, "DB error: {}", e),
AppError::Parse(e) => write!(f, "parse error: {}", e),
}
}
}
impl From<IoError> for AppError {
fn from(e: IoError) -> Self {
AppError::Io(e)
}
}
impl From<DbError> for AppError {
fn from(e: DbError) -> Self {
AppError::Db(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
pub fn read_file(path: &str) -> Result<String, IoError> {
if path == "missing" {
Err(IoError(format!("{}: not found", path)))
} else {
Ok("42".to_string())
}
}
pub fn query_db(n: i32) -> Result<Vec<i32>, DbError> {
if n < 0 {
Err(DbError("negative input".to_string()))
} else {
Ok(vec![n, n * 2, n * 3])
}
}
pub fn pipeline(path: &str) -> Result<Vec<i32>, AppError> {
let content = read_file(path)?;
let n: i32 = content.trim().parse()?;
let rows = query_db(n)?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_ok() {
assert!(pipeline("data.txt").is_ok());
}
#[test]
fn test_pipeline_io_err() {
assert!(matches!(pipeline("missing"), Err(AppError::Io(_))));
}
#[test]
fn test_from_io_error() {
let e: AppError = IoError("test".to_string()).into();
assert!(matches!(e, AppError::Io(_)));
}
#[test]
fn test_from_db_error() {
let e: AppError = DbError("test".to_string()).into();
assert!(matches!(e, AppError::Db(_)));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_ok() {
assert!(pipeline("data.txt").is_ok());
}
#[test]
fn test_pipeline_io_err() {
assert!(matches!(pipeline("missing"), Err(AppError::Io(_))));
}
#[test]
fn test_from_io_error() {
let e: AppError = IoError("test".to_string()).into();
assert!(matches!(e, AppError::Io(_)));
}
#[test]
fn test_from_db_error() {
let e: AppError = DbError("test".to_string()).into();
assert!(matches!(e, AppError::Db(_)));
}
}
Deep Comparison
multiple-error-types
See README.md for details.
Exercises
AppError enum, implement its From conversion, and use it in a new function.Box<dyn Error> instead of a custom enum — compare the tradeoffs.AppError to verify that error type conversion is correct for each source.