ExamplesBy LevelBy TopicLearning Paths
311 Intermediate

311: Handling Multiple Error Types

Functional Programming

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

  • • Define an application error enum that unifies multiple library error types
  • • Implement From<E> for each error source to enable ? conversion
  • • Use the ? operator with multiple error types in a single function body
  • • Recognize this as the "error type tower" pattern used in production Rust code
  • Code 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

  • 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.
  • From automation: Rust's ? calls From::from() automatically; OCaml requires explicit Result.map_error at each site.
  • Exhaustive matching: Both require exhaustive match on the error type — adding a variant is a breaking change (Rust) or extends the union (OCaml polymorphic variants).
  • thiserror: #[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(_)));
        }
    }
    ✓ Tests Rust test suite
    #[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

  • Add a fourth error variant to an existing AppError enum, implement its From conversion, and use it in a new function.
  • Implement the same three-error function using Box<dyn Error> instead of a custom enum — compare the tradeoffs.
  • Write a test that matches on each variant of AppError to verify that error type conversion is correct for each source.
  • Open Source Repos