1011-try-operator — The ? (Try) Operator
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
? desugars to: early return + optional From conversionFrom<ParseIntError> for a custom error type to enable ? across error type boundaries?? operator from unwrap and understand the safety trade-off? 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
? calls From::from on the error type automatically; OCaml's let* requires the error types to already match or uses explicit conversion.? desugars to match result { Ok(v) => v, Err(e) => return Err(e.into()) }; OCaml's let* desugars to bind.? only works in functions returning Result<_, _> or Option<_>; OCaml's let* is polymorphic over any monadic type.? 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)));
}
}#[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
match expressions (verbose but clear)>>= bind operator (monadic, Haskell-style)let* binding operators (OCaml 4.08+, most ergonomic)Rust Approach
? operator: expr? desugars to match + early return + From::from(e)From conversion means different error types can coexistResult (or Option)parse(&read(key)?)?Comparison Table
| Aspect | OCaml let* | Rust ? |
|---|---|---|
| Syntax | let* x = expr in | let x = expr?; |
| Error conversion | None (same type required) | Automatic via From |
| Early return | Via continuation | Via return Err(...) |
| Nesting | Flat (monadic) | Flat (early return) |
| Works on | Result (custom) | Result, Option |
| Available since | OCaml 4.08 | Rust 1.13 (try! macro before) |
Exercises
rate_limit(n: i64) -> Result<i64, AppError> and chain it with ? into the existing pipeline.From<ParseIntError> impl and observe the compiler error. Then add a map_err call to fix it manually.AppError values, continuing on error rather than returning early.