ExamplesBy LevelBy TopicLearning Paths
1004 Intermediate

1004 — Error Conversion

Functional Programming

Tutorial

The Problem

Implement From<SubError> for AppError to enable automatic error conversion with the ? operator. When a function returns Result<T, AppError> and calls a sub-function returning Result<T, ParseIntError>, the ? operator automatically wraps the inner error via From::from. Compare with OCaml's explicit manual wrapping.

🎯 Learning Outcomes

  • • Implement impl From<IoError> for AppError and impl From<ParseIntError> for AppError
  • • Understand that ? desugars to map_err(From::from) — calling the From impl
  • • Implement Error::source to expose the wrapped error for error chain inspection
  • • Chain multiple ? calls in a single function without explicit map_err
  • • Map Rust's automatic From-based conversion to OCaml's manual IoError(e) wrapping
  • • Recognise the AppError unified error enum pattern as the idiomatic Rust design
  • Code Example

    // src/lib.rs content
    //! Error conversion patterns using Rust's `From` trait.
    //!
    //! This module mirrors the OCaml example by exposing two approaches:
    //! manual wrapping of sub-errors into a unified `AppError`, and the
    //! idiomatic Rust approach of deriving automatic conversion via `From`
    //! so the `?` operator lifts sub-errors transparently.
    
    use std::fmt;
    
    /// Errors raised by I/O-like operations.
    #[derive(Debug, PartialEq, Eq)]
    pub enum IoError {
        /// The requested path does not exist.
        FileNotFound(String),
        /// The caller lacks permission for the path.
        PermissionDenied(String),
    }
    
    /// Errors raised while parsing configuration values.
    #[derive(Debug, PartialEq, Eq)]
    pub enum ParseError {
        /// The input could not be interpreted as the expected type.
        InvalidFormat(String),
        /// The value parsed but fell outside the accepted range.
        OutOfRange(i64),
    }
    
    /// Top-level application error combining sub-error categories.
    #[derive(Debug, PartialEq, Eq)]
    pub enum AppError {
        /// Wraps an I/O-layer error.
        Io(IoError),
        /// Wraps a parse-layer error.
        Parse(ParseError),
    }
    
    impl fmt::Display for IoError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                IoError::FileNotFound(s) => write!(f, "file not found: {s}"),
                IoError::PermissionDenied(s) => write!(f, "permission denied: {s}"),
            }
        }
    }
    
    impl fmt::Display for ParseError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ParseError::InvalidFormat(s) => write!(f, "invalid format: {s}"),
                ParseError::OutOfRange(n) => write!(f, "out of range: {n}"),
            }
        }
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::Io(e) => write!(f, "IO: {e}"),
                AppError::Parse(e) => write!(f, "Parse: {e}"),
            }
        }
    }
    
    impl std::error::Error for IoError {}
    impl std::error::Error for ParseError {}
    impl std::error::Error for AppError {
        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
            match self {
                AppError::Io(e) => Some(e),
                AppError::Parse(e) => Some(e),
            }
        }
    }
    
    /// Automatic lift from `IoError` into `AppError`. Replaces OCaml's `lift_io`.
    impl From<IoError> for AppError {
        fn from(e: IoError) -> Self {
            AppError::Io(e)
        }
    }
    
    /// Automatic lift from `ParseError` into `AppError`. Replaces OCaml's `lift_parse`.
    impl From<ParseError> for AppError {
        fn from(e: ParseError) -> Self {
            AppError::Parse(e)
        }
    }
    
    /// Approach 1 — manual wrapping: returns `AppError` directly at the call site.
    pub fn read_config_manual(path: &str) -> Result<String, AppError> {
        if path == "/missing" {
            Err(AppError::Io(IoError::FileNotFound(path.to_string())))
        } else {
            Ok("42".to_string())
        }
    }
    
    /// Approach 1 — manual wrapping for a parser that hand-constructs `AppError`.
    pub fn parse_value_manual(s: &str) -> Result<i64, AppError> {
        match s.parse::<i64>() {
            Err(_) => Err(AppError::Parse(ParseError::InvalidFormat(s.to_string()))),
            Ok(n) if n < 0 => Err(AppError::Parse(ParseError::OutOfRange(n))),
            Ok(n) => Ok(n),
        }
    }
    
    /// Approach 2 — raw I/O returning its own error type.
    pub fn read_raw(path: &str) -> Result<String, IoError> {
        if path == "/missing" {
            Err(IoError::FileNotFound(path.to_string()))
        } else {
            Ok("42".to_string())
        }
    }
    
    /// Approach 2 — raw parser returning its own error type.
    pub fn parse_raw(s: &str) -> Result<i64, ParseError> {
        s.parse::<i64>()
            .map_err(|_| ParseError::InvalidFormat(s.to_string()))
    }
    
    /// Approach 2 — composes `read_raw` and `parse_raw` using `?`.
    ///
    /// The `?` operator invokes `From::from` on each sub-error, so explicit
    /// `lift_io` / `lift_parse` helpers are unnecessary.
    pub fn load_config(path: &str) -> Result<i64, AppError> {
        let s = read_raw(path)?;
        let n = parse_raw(&s)?;
        Ok(n)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn manual_read_ok() {
            assert_eq!(read_config_manual("/ok"), Ok("42".to_string()));
        }
    
        #[test]
        fn manual_read_missing() {
            assert!(matches!(
                read_config_manual("/missing"),
                Err(AppError::Io(IoError::FileNotFound(_)))
            ));
        }
    
        #[test]
        fn manual_parse_ok() {
            assert_eq!(parse_value_manual("42"), Ok(42));
        }
    
        #[test]
        fn manual_parse_invalid() {
            assert!(matches!(
                parse_value_manual("abc"),
                Err(AppError::Parse(ParseError::InvalidFormat(_)))
            ));
        }
    
        #[test]
        fn manual_parse_out_of_range() {
            assert!(matches!(
                parse_value_manual("-5"),
                Err(AppError::Parse(ParseError::OutOfRange(-5)))
            ));
        }
    
        #[test]
        fn lift_load_ok() {
            assert_eq!(load_config("/ok"), Ok(42));
        }
    
        #[test]
        fn lift_load_missing() {
            assert!(matches!(
                load_config("/missing"),
                Err(AppError::Io(IoError::FileNotFound(_)))
            ));
        }
    
        #[test]
        fn from_trait_converts_io_error() {
            let e: AppError = IoError::PermissionDenied("/etc".to_string()).into();
            assert!(matches!(e, AppError::Io(IoError::PermissionDenied(_))));
        }
    
        #[test]
        fn from_trait_converts_parse_error() {
            let e: AppError = ParseError::OutOfRange(7).into();
            assert!(matches!(e, AppError::Parse(ParseError::OutOfRange(7))));
        }
    
        #[test]
        fn display_formats_nested_error() {
            let e = AppError::Io(IoError::FileNotFound("/x".to_string()));
            assert_eq!(e.to_string(), "IO: file not found: /x");
        }
    
        #[test]
        fn error_source_chain_is_present() {
            use std::error::Error;
            let e = load_config("/missing").unwrap_err();
            assert!(e.source().is_some());
        }
    }

    Key Differences

    AspectRustOCaml
    Auto-conversionFrom impl + ?Manual wrapping IoError(e)
    ? operatormap_err(From::from) + early returnlet* bind + manual error lifting
    Error chainError::source()No standard protocol
    Wrapper enumAppError::Io(e), AppError::Parse(e)Same variant wrapping
    From boilerplate5-line impl per error typeManual fun e -> IoError e at each call
    thiserror#[from] attribute eliminates FromNo equivalent

    The From + ? pattern is one of Rust's most important ergonomic features. Writing some_fallible_call()? in a function returning Result<T, AppError> automatically converts any matching sub-error type. This enables clean, readable error propagation without noise.

    OCaml Approach

    OCaml wraps errors manually: Error (IoError (FileNotFound path)). There is no ?-equivalent or automatic conversion. Functions returning app_error Result must explicitly tag sub-errors: Result.map_error (fun e -> IoError e) (read_file path). OCaml 4.08+ provides let* (monadic bind) for result chaining, but conversion is still explicit.

    Full Source

    // src/lib.rs content
    //! Error conversion patterns using Rust's `From` trait.
    //!
    //! This module mirrors the OCaml example by exposing two approaches:
    //! manual wrapping of sub-errors into a unified `AppError`, and the
    //! idiomatic Rust approach of deriving automatic conversion via `From`
    //! so the `?` operator lifts sub-errors transparently.
    
    use std::fmt;
    
    /// Errors raised by I/O-like operations.
    #[derive(Debug, PartialEq, Eq)]
    pub enum IoError {
        /// The requested path does not exist.
        FileNotFound(String),
        /// The caller lacks permission for the path.
        PermissionDenied(String),
    }
    
    /// Errors raised while parsing configuration values.
    #[derive(Debug, PartialEq, Eq)]
    pub enum ParseError {
        /// The input could not be interpreted as the expected type.
        InvalidFormat(String),
        /// The value parsed but fell outside the accepted range.
        OutOfRange(i64),
    }
    
    /// Top-level application error combining sub-error categories.
    #[derive(Debug, PartialEq, Eq)]
    pub enum AppError {
        /// Wraps an I/O-layer error.
        Io(IoError),
        /// Wraps a parse-layer error.
        Parse(ParseError),
    }
    
    impl fmt::Display for IoError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                IoError::FileNotFound(s) => write!(f, "file not found: {s}"),
                IoError::PermissionDenied(s) => write!(f, "permission denied: {s}"),
            }
        }
    }
    
    impl fmt::Display for ParseError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ParseError::InvalidFormat(s) => write!(f, "invalid format: {s}"),
                ParseError::OutOfRange(n) => write!(f, "out of range: {n}"),
            }
        }
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::Io(e) => write!(f, "IO: {e}"),
                AppError::Parse(e) => write!(f, "Parse: {e}"),
            }
        }
    }
    
    impl std::error::Error for IoError {}
    impl std::error::Error for ParseError {}
    impl std::error::Error for AppError {
        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
            match self {
                AppError::Io(e) => Some(e),
                AppError::Parse(e) => Some(e),
            }
        }
    }
    
    /// Automatic lift from `IoError` into `AppError`. Replaces OCaml's `lift_io`.
    impl From<IoError> for AppError {
        fn from(e: IoError) -> Self {
            AppError::Io(e)
        }
    }
    
    /// Automatic lift from `ParseError` into `AppError`. Replaces OCaml's `lift_parse`.
    impl From<ParseError> for AppError {
        fn from(e: ParseError) -> Self {
            AppError::Parse(e)
        }
    }
    
    /// Approach 1 — manual wrapping: returns `AppError` directly at the call site.
    pub fn read_config_manual(path: &str) -> Result<String, AppError> {
        if path == "/missing" {
            Err(AppError::Io(IoError::FileNotFound(path.to_string())))
        } else {
            Ok("42".to_string())
        }
    }
    
    /// Approach 1 — manual wrapping for a parser that hand-constructs `AppError`.
    pub fn parse_value_manual(s: &str) -> Result<i64, AppError> {
        match s.parse::<i64>() {
            Err(_) => Err(AppError::Parse(ParseError::InvalidFormat(s.to_string()))),
            Ok(n) if n < 0 => Err(AppError::Parse(ParseError::OutOfRange(n))),
            Ok(n) => Ok(n),
        }
    }
    
    /// Approach 2 — raw I/O returning its own error type.
    pub fn read_raw(path: &str) -> Result<String, IoError> {
        if path == "/missing" {
            Err(IoError::FileNotFound(path.to_string()))
        } else {
            Ok("42".to_string())
        }
    }
    
    /// Approach 2 — raw parser returning its own error type.
    pub fn parse_raw(s: &str) -> Result<i64, ParseError> {
        s.parse::<i64>()
            .map_err(|_| ParseError::InvalidFormat(s.to_string()))
    }
    
    /// Approach 2 — composes `read_raw` and `parse_raw` using `?`.
    ///
    /// The `?` operator invokes `From::from` on each sub-error, so explicit
    /// `lift_io` / `lift_parse` helpers are unnecessary.
    pub fn load_config(path: &str) -> Result<i64, AppError> {
        let s = read_raw(path)?;
        let n = parse_raw(&s)?;
        Ok(n)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn manual_read_ok() {
            assert_eq!(read_config_manual("/ok"), Ok("42".to_string()));
        }
    
        #[test]
        fn manual_read_missing() {
            assert!(matches!(
                read_config_manual("/missing"),
                Err(AppError::Io(IoError::FileNotFound(_)))
            ));
        }
    
        #[test]
        fn manual_parse_ok() {
            assert_eq!(parse_value_manual("42"), Ok(42));
        }
    
        #[test]
        fn manual_parse_invalid() {
            assert!(matches!(
                parse_value_manual("abc"),
                Err(AppError::Parse(ParseError::InvalidFormat(_)))
            ));
        }
    
        #[test]
        fn manual_parse_out_of_range() {
            assert!(matches!(
                parse_value_manual("-5"),
                Err(AppError::Parse(ParseError::OutOfRange(-5)))
            ));
        }
    
        #[test]
        fn lift_load_ok() {
            assert_eq!(load_config("/ok"), Ok(42));
        }
    
        #[test]
        fn lift_load_missing() {
            assert!(matches!(
                load_config("/missing"),
                Err(AppError::Io(IoError::FileNotFound(_)))
            ));
        }
    
        #[test]
        fn from_trait_converts_io_error() {
            let e: AppError = IoError::PermissionDenied("/etc".to_string()).into();
            assert!(matches!(e, AppError::Io(IoError::PermissionDenied(_))));
        }
    
        #[test]
        fn from_trait_converts_parse_error() {
            let e: AppError = ParseError::OutOfRange(7).into();
            assert!(matches!(e, AppError::Parse(ParseError::OutOfRange(7))));
        }
    
        #[test]
        fn display_formats_nested_error() {
            let e = AppError::Io(IoError::FileNotFound("/x".to_string()));
            assert_eq!(e.to_string(), "IO: file not found: /x");
        }
    
        #[test]
        fn error_source_chain_is_present() {
            use std::error::Error;
            let e = load_config("/missing").unwrap_err();
            assert!(e.source().is_some());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn manual_read_ok() {
            assert_eq!(read_config_manual("/ok"), Ok("42".to_string()));
        }
    
        #[test]
        fn manual_read_missing() {
            assert!(matches!(
                read_config_manual("/missing"),
                Err(AppError::Io(IoError::FileNotFound(_)))
            ));
        }
    
        #[test]
        fn manual_parse_ok() {
            assert_eq!(parse_value_manual("42"), Ok(42));
        }
    
        #[test]
        fn manual_parse_invalid() {
            assert!(matches!(
                parse_value_manual("abc"),
                Err(AppError::Parse(ParseError::InvalidFormat(_)))
            ));
        }
    
        #[test]
        fn manual_parse_out_of_range() {
            assert!(matches!(
                parse_value_manual("-5"),
                Err(AppError::Parse(ParseError::OutOfRange(-5)))
            ));
        }
    
        #[test]
        fn lift_load_ok() {
            assert_eq!(load_config("/ok"), Ok(42));
        }
    
        #[test]
        fn lift_load_missing() {
            assert!(matches!(
                load_config("/missing"),
                Err(AppError::Io(IoError::FileNotFound(_)))
            ));
        }
    
        #[test]
        fn from_trait_converts_io_error() {
            let e: AppError = IoError::PermissionDenied("/etc".to_string()).into();
            assert!(matches!(e, AppError::Io(IoError::PermissionDenied(_))));
        }
    
        #[test]
        fn from_trait_converts_parse_error() {
            let e: AppError = ParseError::OutOfRange(7).into();
            assert!(matches!(e, AppError::Parse(ParseError::OutOfRange(7))));
        }
    
        #[test]
        fn display_formats_nested_error() {
            let e = AppError::Io(IoError::FileNotFound("/x".to_string()));
            assert_eq!(e.to_string(), "IO: file not found: /x");
        }
    
        #[test]
        fn error_source_chain_is_present() {
            use std::error::Error;
            let e = load_config("/missing").unwrap_err();
            assert!(e.source().is_some());
        }
    }

    Deep Comparison

    Error Conversion — Comparison

    Core Insight

    Rust's From trait + ? operator automates what OCaml forces you to do manually: wrapping sub-errors into a unified error type.

    OCaml Approach

  • • Must manually wrap each sub-error: Error (IoError e) at every call site
  • • Can write lift_* helper functions but they're boilerplate
  • • No language-level support for automatic error conversion
  • • Each new error source means another wrapper call
  • Rust Approach

  • • Implement From<SubError> for UnifiedError once per sub-error type
  • • The ? operator automatically calls .into() which uses From
  • • Adding a new error source = one new From impl, zero call-site changes
  • • The source() method preserves the error chain
  • Comparison Table

    AspectOCamlRust
    Conversion mechanismManual wrappingFrom trait + ?
    Boilerplate per call siteOne wrapper per callZero (automatic)
    Adding new error sourceTouch every call siteOne From impl
    Error chainManual nestingsource() method
    Type safetyVariant pattern matchSame + compiler enforced

    Exercises

  • Add a third sub-error DbError(String) to AppError with a From<DbError> for AppError impl.
  • Implement fn process_all(items: Vec<&str>) -> Result<Vec<i32>, AppError> that parses all items, collecting the first error.
  • Use Result::map_err manually to convert an IoError to AppError without the From impl, and compare verbosity.
  • Add impl std::error::Error::source chaining for three levels: AppErrorIoErrorstd::io::Error.
  • In OCaml, implement map_error : ('a -> 'b) -> ('c, 'a) result -> ('c, 'b) result and use it to build a clean error-lifting pipeline.
  • Open Source Repos