ExamplesBy LevelBy TopicLearning Paths
1011 Intermediate

1011-try-operator — The ? (Try) Operator

Functional Programming

Tutorial

The Problem

Explicit error propagation is verbose. Without language support, every fallible call requires a match block to check for errors and forward them upward. In a deep call stack, this repetition obscures the happy-path logic. Haskell's do notation and OCaml's let* binding both address this. Rust's ? operator is the compile-time desugaring of the same idea: it extracts the Ok value or returns the Err early, optionally converting the error type via From.

The ? operator is foundational in Rust: virtually all production code that handles I/O, parsing, or network operations uses it.

🎯 Learning Outcomes

  • • Understand what ? desugars to: early return + optional From conversion
  • • Implement From<ParseIntError> for a custom error type to enable ? across error type boundaries
  • • Chain multiple fallible calls in a single function body using ?
  • • Distinguish the ? operator from unwrap and understand the safety trade-off
  • • Know when ? can and cannot be used (function return type must implement Try)
  • Code Example

    #![allow(clippy::all)]
    // 1011: The ? (Try) Operator
    // Deep dive: early return, From conversion, desugaring
    
    use std::fmt;
    use std::num::ParseIntError;
    
    #[derive(Debug, PartialEq)]
    enum AppError {
        NotFound,
        ParseFailed(String),
        TooLarge(i64),
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::NotFound => write!(f, "not found"),
                AppError::ParseFailed(s) => write!(f, "parse failed: {}", s),
                AppError::TooLarge(n) => write!(f, "too large: {}", n),
            }
        }
    }
    impl std::error::Error for AppError {}
    
    impl From<ParseIntError> for AppError {
        fn from(e: ParseIntError) -> Self {
            AppError::ParseFailed(e.to_string())
        }
    }
    
    fn read_data(key: &str) -> Result<String, AppError> {
        if key == "missing" {
            Err(AppError::NotFound)
        } else {
            Ok("42".into())
        }
    }
    
    fn parse_data(s: &str) -> Result<i64, ParseIntError> {
        s.parse::<i64>()
    }
    
    fn validate(n: i64) -> Result<i64, AppError> {
        if n > 100 {
            Err(AppError::TooLarge(n))
        } else {
            Ok(n)
        }
    }
    
    // Approach 1: The ? operator — what it looks like
    fn process_try(key: &str) -> Result<i64, AppError> {
        let s = read_data(key)?; // early return if Err
        let n = parse_data(&s)?; // ParseIntError -> AppError via From
        let v = validate(n)?; // early return if Err
        Ok(v)
    }
    
    // Approach 2: What ? desugars to (approximately)
    fn process_desugared(key: &str) -> Result<i64, AppError> {
        let s = match read_data(key) {
            Ok(v) => v,
            Err(e) => return Err(From::from(e)),
        };
        let n = match parse_data(&s) {
            Ok(v) => v,
            Err(e) => return Err(From::from(e)), // From<ParseIntError>
        };
        let v = match validate(n) {
            Ok(v) => v,
            Err(e) => return Err(From::from(e)),
        };
        Ok(v)
    }
    
    // Approach 3: ? in expression position
    fn process_inline(key: &str) -> Result<i64, AppError> {
        validate(parse_data(&read_data(key)?)?)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_try_success() {
            assert_eq!(process_try("ok"), Ok(42));
        }
    
        #[test]
        fn test_try_not_found() {
            assert_eq!(process_try("missing"), Err(AppError::NotFound));
        }
    
        #[test]
        fn test_desugared_matches_try() {
            assert_eq!(process_try("ok"), process_desugared("ok"));
            assert_eq!(process_try("missing"), process_desugared("missing"));
        }
    
        #[test]
        fn test_inline_matches_try() {
            assert_eq!(process_try("ok"), process_inline("ok"));
            assert_eq!(process_try("missing"), process_inline("missing"));
        }
    
        #[test]
        fn test_from_conversion() {
            // ? calls From::from on the error
            let parse_err = "abc".parse::<i64>().unwrap_err();
            let app_err: AppError = parse_err.into();
            assert!(matches!(app_err, AppError::ParseFailed(_)));
        }
    
        #[test]
        fn test_try_in_closure() {
            // ? works in closures that return Result
            let process = |key: &str| -> Result<i64, AppError> {
                let s = read_data(key)?;
                Ok(s.parse::<i64>()?)
            };
            assert_eq!(process("ok"), Ok(42));
        }
    
        #[test]
        fn test_too_large() {
            // If we had data "200", validate would fail
            fn process_large() -> Result<i64, AppError> {
                let n: i64 = "200"
                    .parse()
                    .map_err(|e: ParseIntError| AppError::ParseFailed(e.to_string()))?;
                validate(n)
            }
            assert_eq!(process_large(), Err(AppError::TooLarge(200)));
        }
    }

    Key Differences

  • Implicit type conversion: Rust's ? calls From::from on the error type automatically; OCaml's let* requires the error types to already match or uses explicit conversion.
  • Desugaring: ? desugars to match result { Ok(v) => v, Err(e) => return Err(e.into()) }; OCaml's let* desugars to bind.
  • Return type requirement: Rust's ? only works in functions returning Result<_, _> or Option<_>; OCaml's let* is polymorphic over any monadic type.
  • Stack context: Rust's ? with anyhow::Context can attach human-readable context to each propagation point; OCaml requires manual wrapping.
  • OCaml Approach

    OCaml's let* syntax (available via Result.bind in a let* = Result.bind binding) provides equivalent ergonomics:

    let ( let* ) = Result.bind
    
    let pipeline key =
      let* data = read_data key in
      let* n = parse_data data in
      validate n
    

    Without let*, each step requires explicit match or |> with Result.bind. Unlike Rust's From, OCaml requires explicit type conversion between error variants.

    Full Source

    #![allow(clippy::all)]
    // 1011: The ? (Try) Operator
    // Deep dive: early return, From conversion, desugaring
    
    use std::fmt;
    use std::num::ParseIntError;
    
    #[derive(Debug, PartialEq)]
    enum AppError {
        NotFound,
        ParseFailed(String),
        TooLarge(i64),
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::NotFound => write!(f, "not found"),
                AppError::ParseFailed(s) => write!(f, "parse failed: {}", s),
                AppError::TooLarge(n) => write!(f, "too large: {}", n),
            }
        }
    }
    impl std::error::Error for AppError {}
    
    impl From<ParseIntError> for AppError {
        fn from(e: ParseIntError) -> Self {
            AppError::ParseFailed(e.to_string())
        }
    }
    
    fn read_data(key: &str) -> Result<String, AppError> {
        if key == "missing" {
            Err(AppError::NotFound)
        } else {
            Ok("42".into())
        }
    }
    
    fn parse_data(s: &str) -> Result<i64, ParseIntError> {
        s.parse::<i64>()
    }
    
    fn validate(n: i64) -> Result<i64, AppError> {
        if n > 100 {
            Err(AppError::TooLarge(n))
        } else {
            Ok(n)
        }
    }
    
    // Approach 1: The ? operator — what it looks like
    fn process_try(key: &str) -> Result<i64, AppError> {
        let s = read_data(key)?; // early return if Err
        let n = parse_data(&s)?; // ParseIntError -> AppError via From
        let v = validate(n)?; // early return if Err
        Ok(v)
    }
    
    // Approach 2: What ? desugars to (approximately)
    fn process_desugared(key: &str) -> Result<i64, AppError> {
        let s = match read_data(key) {
            Ok(v) => v,
            Err(e) => return Err(From::from(e)),
        };
        let n = match parse_data(&s) {
            Ok(v) => v,
            Err(e) => return Err(From::from(e)), // From<ParseIntError>
        };
        let v = match validate(n) {
            Ok(v) => v,
            Err(e) => return Err(From::from(e)),
        };
        Ok(v)
    }
    
    // Approach 3: ? in expression position
    fn process_inline(key: &str) -> Result<i64, AppError> {
        validate(parse_data(&read_data(key)?)?)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_try_success() {
            assert_eq!(process_try("ok"), Ok(42));
        }
    
        #[test]
        fn test_try_not_found() {
            assert_eq!(process_try("missing"), Err(AppError::NotFound));
        }
    
        #[test]
        fn test_desugared_matches_try() {
            assert_eq!(process_try("ok"), process_desugared("ok"));
            assert_eq!(process_try("missing"), process_desugared("missing"));
        }
    
        #[test]
        fn test_inline_matches_try() {
            assert_eq!(process_try("ok"), process_inline("ok"));
            assert_eq!(process_try("missing"), process_inline("missing"));
        }
    
        #[test]
        fn test_from_conversion() {
            // ? calls From::from on the error
            let parse_err = "abc".parse::<i64>().unwrap_err();
            let app_err: AppError = parse_err.into();
            assert!(matches!(app_err, AppError::ParseFailed(_)));
        }
    
        #[test]
        fn test_try_in_closure() {
            // ? works in closures that return Result
            let process = |key: &str| -> Result<i64, AppError> {
                let s = read_data(key)?;
                Ok(s.parse::<i64>()?)
            };
            assert_eq!(process("ok"), Ok(42));
        }
    
        #[test]
        fn test_too_large() {
            // If we had data "200", validate would fail
            fn process_large() -> Result<i64, AppError> {
                let n: i64 = "200"
                    .parse()
                    .map_err(|e: ParseIntError| AppError::ParseFailed(e.to_string()))?;
                validate(n)
            }
            assert_eq!(process_large(), Err(AppError::TooLarge(200)));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_try_success() {
            assert_eq!(process_try("ok"), Ok(42));
        }
    
        #[test]
        fn test_try_not_found() {
            assert_eq!(process_try("missing"), Err(AppError::NotFound));
        }
    
        #[test]
        fn test_desugared_matches_try() {
            assert_eq!(process_try("ok"), process_desugared("ok"));
            assert_eq!(process_try("missing"), process_desugared("missing"));
        }
    
        #[test]
        fn test_inline_matches_try() {
            assert_eq!(process_try("ok"), process_inline("ok"));
            assert_eq!(process_try("missing"), process_inline("missing"));
        }
    
        #[test]
        fn test_from_conversion() {
            // ? calls From::from on the error
            let parse_err = "abc".parse::<i64>().unwrap_err();
            let app_err: AppError = parse_err.into();
            assert!(matches!(app_err, AppError::ParseFailed(_)));
        }
    
        #[test]
        fn test_try_in_closure() {
            // ? works in closures that return Result
            let process = |key: &str| -> Result<i64, AppError> {
                let s = read_data(key)?;
                Ok(s.parse::<i64>()?)
            };
            assert_eq!(process("ok"), Ok(42));
        }
    
        #[test]
        fn test_too_large() {
            // If we had data "200", validate would fail
            fn process_large() -> Result<i64, AppError> {
                let n: i64 = "200"
                    .parse()
                    .map_err(|e: ParseIntError| AppError::ParseFailed(e.to_string()))?;
                validate(n)
            }
            assert_eq!(process_large(), Err(AppError::TooLarge(200)));
        }
    }

    Deep Comparison

    The ? (Try) Operator — Comparison

    Core Insight

    Rust's ? is the ergonomic equivalent of OCaml's let* binding operator — both eliminate nested match/bind chains while keeping error handling explicit and typed.

    OCaml Approach

  • • Nested match expressions (verbose but clear)
  • >>= bind operator (monadic, Haskell-style)
  • let* binding operators (OCaml 4.08+, most ergonomic)
  • • All three require the same error type throughout
  • Rust Approach

  • ? operator: expr? desugars to match + early return + From::from(e)
  • • Automatic From conversion means different error types can coexist
  • • Works in any function returning Result (or Option)
  • • Can be used in expression position: parse(&read(key)?)?
  • Comparison Table

    AspectOCaml let*Rust ?
    Syntaxlet* x = expr inlet x = expr?;
    Error conversionNone (same type required)Automatic via From
    Early returnVia continuationVia return Err(...)
    NestingFlat (monadic)Flat (early return)
    Works onResult (custom)Result, Option
    Available sinceOCaml 4.08Rust 1.13 (try! macro before)

    Exercises

  • Add a fifth pipeline stage rate_limit(n: i64) -> Result<i64, AppError> and chain it with ? into the existing pipeline.
  • Remove the From<ParseIntError> impl and observe the compiler error. Then add a map_err call to fix it manually.
  • Write a function that calls the pipeline in a loop for a list of keys and collects all AppError values, continuing on error rather than returning early.
  • Open Source Repos