313: The Try Trait — What ? Actually Does
Tutorial Video
Text description (accessibility)
This video demonstrates the "313: The Try Trait — What ? Actually Does" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. The `?` operator desugars to a call to the `Try` trait (unstable) or the earlier `From` + early-return pattern. Key difference from OCaml: 1. **Monad vs applicative**: Short
Tutorial
The Problem
The ? operator desugars to a call to the Try trait (unstable) or the earlier From + early-return pattern. This example demonstrates the concept using a Validated<T, E> type that accumulates multiple errors instead of short-circuiting — illustrating that the ? behavior is customizable. Understanding what ? actually does enables implementing custom types that participate in Rust's error-handling ergonomics.
🎯 Learning Outcomes
? as desugaring to: extract value or convert error and return earlyValidated type that accumulates errors instead of short-circuiting? semantics are defined by the return type, not the operator itselfResult and Option implement the early-return contract that ? relies onCode Example
#![allow(clippy::all)]
//! # The Try Trait and Custom ? Behavior
//!
//! Validated type that accumulates errors instead of short-circuiting.
/// Validated type - accumulates errors applicatively
#[derive(Debug, PartialEq)]
pub enum Validated<T, E> {
Ok(T),
Err(Vec<E>),
}
impl<T, E> Validated<T, E> {
pub fn ok(v: T) -> Self {
Validated::Ok(v)
}
pub fn err(e: E) -> Self {
Validated::Err(vec![e])
}
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U, E> {
match self {
Validated::Ok(v) => Validated::Ok(f(v)),
Validated::Err(es) => Validated::Err(es),
}
}
pub fn and<U>(self, other: Validated<U, E>) -> Validated<(T, U), E> {
match (self, other) {
(Validated::Ok(a), Validated::Ok(b)) => Validated::Ok((a, b)),
(Validated::Err(mut e1), Validated::Err(e2)) => {
e1.extend(e2);
Validated::Err(e1)
}
(Validated::Err(e), _) | (_, Validated::Err(e)) => Validated::Err(e),
}
}
pub fn is_ok(&self) -> bool {
matches!(self, Validated::Ok(_))
}
}
pub fn validate_age(age: i32) -> Validated<i32, String> {
if age >= 0 && age <= 150 {
Validated::ok(age)
} else {
Validated::err(format!("age {} is out of range", age))
}
}
pub fn validate_name(name: &str) -> Validated<String, String> {
if name.len() >= 2 && name.len() <= 50 {
Validated::ok(name.to_string())
} else {
Validated::err(format!("name '{}' is invalid", name))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validated_ok() {
assert_eq!(validate_age(25), Validated::Ok(25));
}
#[test]
fn test_validated_err() {
assert!(matches!(validate_age(999), Validated::Err(_)));
}
#[test]
fn test_accumulate_errors() {
let r = validate_age(999).and(validate_name("X"));
if let Validated::Err(errs) = r {
assert_eq!(errs.len(), 2);
} else {
panic!("Expected Err");
}
}
#[test]
fn test_and_both_ok() {
let r = validate_age(25).and(validate_name("Alice"));
assert!(r.is_ok());
}
#[test]
fn test_map() {
let r = validate_age(25).map(|a| a * 2);
assert_eq!(r, Validated::Ok(50));
}
}Key Differences
Result, Option) is monadic; error accumulation (Validated) is applicative — fundamentally different composition strategies.? limitation**: Rust's ? is monadic (short-circuit only); accumulation requires explicit and() or similar applicative operations.Validation type in validation crate / Data.Validation directly mirrors this; PureScript, Elm, and other FP languages have similar types.OCaml Approach
OCaml's let* desugars to bind — the behavior is determined by the monad, not the syntax. A Validated monad in OCaml accumulates errors in its bind (applicative) form:
(* Applicative validation: both branches evaluated, errors accumulated *)
let validate_both v1 v2 = match (v1, v2) with
| (Valid x, Valid y) -> Valid (x, y)
| (Invalid e1, Invalid e2) -> Invalid (e1 @ e2)
| (Invalid e, _) | (_, Invalid e) -> Invalid e
Full Source
#![allow(clippy::all)]
//! # The Try Trait and Custom ? Behavior
//!
//! Validated type that accumulates errors instead of short-circuiting.
/// Validated type - accumulates errors applicatively
#[derive(Debug, PartialEq)]
pub enum Validated<T, E> {
Ok(T),
Err(Vec<E>),
}
impl<T, E> Validated<T, E> {
pub fn ok(v: T) -> Self {
Validated::Ok(v)
}
pub fn err(e: E) -> Self {
Validated::Err(vec![e])
}
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U, E> {
match self {
Validated::Ok(v) => Validated::Ok(f(v)),
Validated::Err(es) => Validated::Err(es),
}
}
pub fn and<U>(self, other: Validated<U, E>) -> Validated<(T, U), E> {
match (self, other) {
(Validated::Ok(a), Validated::Ok(b)) => Validated::Ok((a, b)),
(Validated::Err(mut e1), Validated::Err(e2)) => {
e1.extend(e2);
Validated::Err(e1)
}
(Validated::Err(e), _) | (_, Validated::Err(e)) => Validated::Err(e),
}
}
pub fn is_ok(&self) -> bool {
matches!(self, Validated::Ok(_))
}
}
pub fn validate_age(age: i32) -> Validated<i32, String> {
if age >= 0 && age <= 150 {
Validated::ok(age)
} else {
Validated::err(format!("age {} is out of range", age))
}
}
pub fn validate_name(name: &str) -> Validated<String, String> {
if name.len() >= 2 && name.len() <= 50 {
Validated::ok(name.to_string())
} else {
Validated::err(format!("name '{}' is invalid", name))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validated_ok() {
assert_eq!(validate_age(25), Validated::Ok(25));
}
#[test]
fn test_validated_err() {
assert!(matches!(validate_age(999), Validated::Err(_)));
}
#[test]
fn test_accumulate_errors() {
let r = validate_age(999).and(validate_name("X"));
if let Validated::Err(errs) = r {
assert_eq!(errs.len(), 2);
} else {
panic!("Expected Err");
}
}
#[test]
fn test_and_both_ok() {
let r = validate_age(25).and(validate_name("Alice"));
assert!(r.is_ok());
}
#[test]
fn test_map() {
let r = validate_age(25).map(|a| a * 2);
assert_eq!(r, Validated::Ok(50));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validated_ok() {
assert_eq!(validate_age(25), Validated::Ok(25));
}
#[test]
fn test_validated_err() {
assert!(matches!(validate_age(999), Validated::Err(_)));
}
#[test]
fn test_accumulate_errors() {
let r = validate_age(999).and(validate_name("X"));
if let Validated::Err(errs) = r {
assert_eq!(errs.len(), 2);
} else {
panic!("Expected Err");
}
}
#[test]
fn test_and_both_ok() {
let r = validate_age(25).and(validate_name("Alice"));
assert!(r.is_ok());
}
#[test]
fn test_map() {
let r = validate_age(25).map(|a| a * 2);
assert_eq!(r, Validated::Ok(50));
}
}
Deep Comparison
try-trait
See README.md for details.
Exercises
Validated<T, String> that validates a name (non-empty) and age (18-100) simultaneously, returning all errors if both fail.and_then method to Validated that behaves identically to Result::and_then (short-circuits on Err) — show when to use each.Validated<T, Vec<E>> and Result<T, Vec<E>> — what information is preserved or lost in each direction?