1004 — Error Conversion
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
impl From<IoError> for AppError and impl From<ParseIntError> for AppError? desugars to map_err(From::from) — calling the From implError::source to expose the wrapped error for error chain inspection? calls in a single function without explicit map_errFrom-based conversion to OCaml's manual IoError(e) wrappingAppError unified error enum pattern as the idiomatic Rust designCode 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
| Aspect | Rust | OCaml |
|---|---|---|
| Auto-conversion | From impl + ? | Manual wrapping IoError(e) |
? operator | map_err(From::from) + early return | let* bind + manual error lifting |
| Error chain | Error::source() | No standard protocol |
| Wrapper enum | AppError::Io(e), AppError::Parse(e) | Same variant wrapping |
| From boilerplate | 5-line impl per error type | Manual fun e -> IoError e at each call |
| thiserror | #[from] attribute eliminates From | No 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());
}
}#[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
Error (IoError e) at every call sitelift_* helper functions but they're boilerplateRust Approach
From<SubError> for UnifiedError once per sub-error type? operator automatically calls .into() which uses FromFrom impl, zero call-site changessource() method preserves the error chainComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Conversion mechanism | Manual wrapping | From trait + ? |
| Boilerplate per call site | One wrapper per call | Zero (automatic) |
| Adding new error source | Touch every call site | One From impl |
| Error chain | Manual nesting | source() method |
| Type safety | Variant pattern match | Same + compiler enforced |
Exercises
DbError(String) to AppError with a From<DbError> for AppError impl.fn process_all(items: Vec<&str>) -> Result<Vec<i32>, AppError> that parses all items, collecting the first error.Result::map_err manually to convert an IoError to AppError without the From impl, and compare verbosity.impl std::error::Error::source chaining for three levels: AppError → IoError → std::io::Error.map_error : ('a -> 'b) -> ('c, 'a) result -> ('c, 'b) result and use it to build a clean error-lifting pipeline.