Files
rodin-bot a79249bbff fix: correct drifted citations at commit f53b654
- traits.md: iterator.rs anchor L76→L42 (pub const trait Iterator)
- traits.md: deref.rs anchor L60→L139 (pub const trait Deref)
- error-handling.md: fix variant names CliError::Io→IoError, Parse→ParseError
  to match actual stdlib doc example at convert/mod.rs:557
- concurrency.md: mutex.rs and rwlock.rs moved to sync/poison/ subtree
  (3 link updates: mutex×2, rwlock×1)
2026-05-06 17:22:48 -07:00

19 KiB

Rust Error Handling Patterns

Patterns for error handling in Rust, extracted from the standard library source at rust-lang/rust.

Source: rust-lang/rust at commit f53b654

Stats: 4,702 ? operator usages, 4,059 Result<> types, 347 Error implementations, 18 error enums, 508 From<> impls in library/.


1. Result<T, E> as the Primary Error Vehicle

Source:

library/core/src/result.rs#L557

// library/core/src/result.rs:557
#[must_use = "this `Result` may be an `Err` variant, which should be handled"]
pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

Why

The #[must_use] annotation means the compiler WARNS if you ignore a Result. This is Rust's core error philosophy: errors are values in the type system, not exceptions you can accidentally miss. The compiler is your error-handling auditor.

When to Use

Triggers:

  • Any operation that can fail for reasons outside the caller's control
  • I/O, parsing, network, file operations
  • Anything where the caller should decide how to handle failure

Example — before:

// C-style: return codes that can be ignored
fn parse_port(s: &str) -> i32 {
    match s.parse::<i32>() {
        Ok(n) => n,
        Err(_) => -1,  // magic sentinel value — caller might not check
    }
}

let port = parse_port("abc");
// Caller happily uses -1 without knowing it's an error

Example — after:

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.parse::<u16>()
}

let port = parse_port("abc");
// Compiler warning if you don't handle this Result
// You MUST match, unwrap, or propagate with ?

When NOT to Use

Don't use this when:

  • The operation genuinely cannot fail (use plain return type)
  • Failure represents a programming bug, not a recoverable condition (use panic! — see pattern #7)
  • You're in a context where you can't propagate errors (e.g., trait implementations with fixed signatures)

Over-application example:

// Unnecessary: Vec::push cannot fail (barring OOM, which is a panic)
fn add_item(items: &mut Vec<String>, item: String) -> Result<(), ()> {
    items.push(item);
    Ok(())  // This Result is meaningless — push always succeeds
}

Better alternative:

fn add_item(items: &mut Vec<String>, item: String) {
    items.push(item);
}

2. The ? Operator (Early Return on Error)

Source:

library/core/src/ops/try_trait.rs (Try trait definition)

// The ? operator desugars to approximately:
match expression {
    Ok(val) => val,
    Err(err) => return Err(From::from(err)),
}

4,702 usages of ? in the stdlib library/ directory. It's THE idiomatic way to propagate errors.

Why

Eliminates nested match statements for error propagation. Makes the "happy path" readable as sequential code while still handling every error. The From::from(err) call enables automatic error type conversion.

When to Use

Triggers:

  • You're calling a function that returns Result and want to propagate its error to your caller
  • You have multiple fallible operations in sequence
  • Your function's error type can be converted From the callee's error

Example — before:

fn read_config(path: &str) -> Result<Config, MyError> {
    let contents = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) => return Err(MyError::Io(e)),
    };
    let config = match toml::from_str(&contents) {
        Ok(c) => c,
        Err(e) => return Err(MyError::Parse(e)),
    };
    Ok(config)
}

Example — after:

fn read_config(path: &str) -> Result<Config, MyError> {
    let contents = std::fs::read_to_string(path)?;  // auto-converts via From
    let config = toml::from_str(&contents)?;
    Ok(config)
}

When NOT to Use

Don't use this when:

  • You need to handle the error differently depending on its type (match on it instead)
  • You want to add context before propagating (use .map_err() first)
  • You're in a function that doesn't return Result (use match or if let)

Over-application example:

// Bad: ? discards context about WHERE the error happened
fn process(data: &[u8]) -> Result<(), io::Error> {
    validate(data)?;   // if this fails, caller has no idea which step failed
    transform(data)?;  // same error type, same message — which one broke?
    persist(data)?;
}

Better alternative:

fn process(data: &[u8]) -> Result<(), ProcessError> {
    validate(data).map_err(|e| ProcessError::Validation(e))?;
    transform(data).map_err(|e| ProcessError::Transform(e))?;
    persist(data).map_err(|e| ProcessError::Persist(e))?;
    Ok(())
}

3. Error Enums for Domain Errors

Source:

library/std/src/io/error.rs (ErrorKind enum)

// library/core/src/io/error/kind.rs (ErrorKind variants)
#[non_exhaustive]
pub enum ErrorKind {
    NotFound,
    PermissionDenied,
    ConnectionRefused,
    ConnectionReset,
    ConnectionAborted,
    NotConnected,
    AddrInUse,
    // ... 30+ variants
}

Why

Enums make error matching exhaustive — the compiler tells you when you miss a case. #[non_exhaustive] allows adding variants without breaking downstream code. 18 public error enums in the stdlib.

When to Use

Triggers:

  • Your module has 3+ distinct failure modes
  • Callers need to handle different failures differently
  • You want compile-time exhaustiveness checking

Example — before:

// Stringly-typed errors — no compile-time checking
fn connect(addr: &str) -> Result<Connection, String> {
    if !valid_addr(addr) {
        return Err("invalid address".to_string());
    }
    // Caller does: if err.contains("timeout") { ... } — fragile
}

Example — after:

#[derive(Debug)]
pub enum ConnectError {
    InvalidAddress(String),
    Timeout(Duration),
    Refused,
    TlsFailure(tls::Error),
}

impl fmt::Display for ConnectError { ... }
impl std::error::Error for ConnectError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::TlsFailure(e) => Some(e),
            _ => None,
        }
    }
}

When NOT to Use

Don't use this when:

  • You have only one error condition (just use a simple struct)
  • The error is opaque — callers don't match on it (use a struct with a kind field, like io::Error)
  • You're writing application code, not a library (consider anyhow)

4. impl From for MyError (Error Conversion)

Source:

library/core/src/convert/mod.rs#L460

// The stdlib demonstrates the pattern in documentation:
// library/core/src/convert/mod.rs:557
impl From<io::Error> for CliError {
    fn from(error: io::Error) -> Self {
        CliError::IoError(error)
    }
}
impl From<num::ParseIntError> for CliError {
    fn from(error: num::ParseIntError) -> Self {
        CliError::ParseError(error)
    }
}

508 From<> implementations in the stdlib library/.

Why

Makes the ? operator work across error types. When you write file.read()? in a function returning Result<_, MyError>, the compiler calls MyError::from(io_error) automatically. This is why ? can convert errors — it's powered by From.

When to Use

Triggers:

  • Your error enum wraps errors from multiple dependencies
  • You want ? to work without explicit .map_err()
  • The conversion is infallible and lossless

Example — before:

fn load(path: &str) -> Result<Data, AppError> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| AppError::Io(e))?;   // manual conversion everywhere
    let data = serde_json::from_str(&text)
        .map_err(|e| AppError::Json(e))?;  // repetitive
    Ok(data)
}

Example — after:

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<serde_json::Error> for AppError {
    fn from(e: serde_json::Error) -> Self { AppError::Json(e) }
}

fn load(path: &str) -> Result<Data, AppError> {
    let text = std::fs::read_to_string(path)?;  // From converts automatically
    let data = serde_json::from_str(&text)?;     // clean, linear code
    Ok(data)
}

When NOT to Use

Don't use this when:

  • You need to add context during conversion (use .map_err())
  • The conversion is lossy (implement TryFrom instead)
  • Multiple source types would conflict (can only have one From<T> impl per target type)

5. The Error Trait (Display + Debug + source)

Source:

library/core/src/error.rs#L57

// library/core/src/error.rs
pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        None
    }
}

Why

The trait requires Debug + Display — errors must always be printable. source() creates an error chain: each error can point to its cause, enabling "error: caused by: caused by:" output.

The doc comment mandates: "Error messages are typically concise lowercase sentences without trailing punctuation."

When to Use

Triggers:

  • Any type you intend to use as E in Result<T, E>
  • Any type that will be stored in Box<dyn Error>
  • When your error wraps a lower-level cause

Example — before:

// Just derive Debug and call it a day — loses Display and source chain
#[derive(Debug)]
struct MyError { msg: String }

// Can't use with `Box<dyn Error>`, can't chain, can't Display

Example — after:

#[derive(Debug)]
struct ReadConfigError {
    path: PathBuf,
    source: io::Error,
}

impl fmt::Display for ReadConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "unable to read configuration at {}", self.path.display())
    }
}

impl Error for ReadConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

When NOT to Use

Don't use this when:

  • You're writing a quick script (just use String or anyhow)
  • The error will never cross an API boundary

6. Type Aliases for Result (io::Result, fmt::Result)

Source:

library/std/src/io/error.rs

// library/std/src/io/mod.rs
pub type Result<T> = result::Result<T, Error>;

Why

Reduces boilerplate in modules where one error type dominates. Instead of writing Result<Vec<u8>, io::Error> everywhere, you write io::Result<Vec<u8>>. Every function in std::io uses this.

When to Use

Triggers:

  • Your module has many functions that all return the same error type
  • The convention is clear and well-documented (module-scoped)

Example — before:

pub fn read(path: &Path) -> Result<Vec<u8>, io::Error> { ... }
pub fn write(path: &Path, data: &[u8]) -> Result<(), io::Error> { ... }
pub fn copy(from: &Path, to: &Path) -> Result<u64, io::Error> { ... }

Example — after:

pub type Result<T> = std::result::Result<T, Error>;

pub fn read(path: &Path) -> Result<Vec<u8>> { ... }
pub fn write(path: &Path, data: &[u8]) -> Result<()> { ... }
pub fn copy(from: &Path, to: &Path) -> Result<u64> { ... }

When NOT to Use

Don't use this when:

  • Functions in your module return different error types
  • The alias would shadow std::result::Result confusingly
  • You're writing application code (just use the full type)

7. panic! for Unrecoverable Bugs

Source:

library/core/src/macros/mod.rs (panic! macro)

514 panic! calls in library/ (non-test).

// Common patterns:
panic!("index out of bounds: the len is {len} but the index is {index}");
panic!("internal error: entered unreachable code");

Why

Panics are for programmer errors — bugs that should never happen in correct code. They unwind the stack (or abort). The distinction: Result = expected failure, panic! = bug.

When to Use

Triggers:

  • Invariant violation (should be impossible if the code is correct)
  • Index out of bounds on internal data structures
  • Unrecoverable state corruption
  • unreachable!() branches

Example — before:

// Returning errors for things that are actually bugs:
fn get_cached(&self, key: &str) -> Result<&Value, CacheError> {
    self.cache.get(key).ok_or(CacheError::NotFound)
    // If this key was SUPPOSED to be there, this is a bug, not an error
}

Example — after:

fn get_cached(&self, key: &str) -> &Value {
    self.cache.get(key)
        .expect("cache invariant violated: key must exist after initialization")
    // Panic with a useful message — this is a bug in OUR code
}

When NOT to Use

Don't use this when:

  • The failure can be caused by user input (use Result)
  • The failure can be caused by external systems (use Result)
  • You're writing a library (let the caller decide how to handle it)
  • Recovery is possible and meaningful

Anti-pattern

// DON'T: panic on user-facing errors
fn parse_config(input: &str) -> Config {
    toml::from_str(input).unwrap()  // panics on invalid TOML — terrible UX
}

// DO: return Result for anything that depends on external input
fn parse_config(input: &str) -> Result<Config, toml::de::Error> {
    toml::from_str(input)
}

8. .map_err() for Adding Context

Source:

library/core/src/result.rs

// library/core/src/result.rs
pub fn map_err<F, O: FnOnce(E) -> F>(self, op: O) -> Result<T, F> {
    match self {
        Ok(t) => Ok(t),
        Err(e) => Err(op(e)),
    }
}

Why

When ? alone doesn't provide enough context — you know WHAT failed but not WHERE or WHY — .map_err() lets you add information before propagating.

When to Use

Triggers:

  • The error type needs context (which file? which step?)
  • You can't implement From because the same source error type appears in multiple places with different meanings
  • You want custom error messages per call site

Example — before:

fn load_users(path: &Path) -> Result<Vec<User>, AppError> {
    let data = std::fs::read_to_string(path)?;  // error: "No such file"
    // But WHICH file? The caller has no idea what path was attempted.
}

Example — after:

fn load_users(path: &Path) -> Result<Vec<User>, AppError> {
    let data = std::fs::read_to_string(path)
        .map_err(|e| AppError::FileRead { path: path.to_owned(), source: e })?;
    // error: "failed to read users file at /etc/users.json: No such file"
}

When NOT to Use

Don't use this when:

  • From conversion provides enough context
  • You're adding noise ("error: an error occurred" — not helpful)
  • The caller already knows the context

9. #[must_use] on Result-Returning Functions

Source:

library/core/src/result.rs#L557

#[must_use = "this `Result` may be an `Err` variant, which should be handled"]
pub enum Result<T, E> { ... }

1,963 #[must_use] annotations across the stdlib.

Why

The compiler warns when a #[must_use] value is discarded. Since Result itself is #[must_use], every function returning Result automatically gets this protection. Additional #[must_use] on functions adds a custom warning message.

When to Use

Triggers:

  • Functions where ignoring the return value is always a bug
  • Builder methods that return a new value (not &mut self)
  • Functions with side effects where the Result indicates success

When NOT to Use

Don't use this when:

  • The function is called primarily for side effects and the return value is informational (e.g., write! in logging — you might legitimately not care)

10. unwrap()/expect() — Only in Tests and Invariants

Source:

1,790 unwrap()/expect() calls in library/ (non-test). These are concentrated in:

  • Internal invariant checks (the value MUST be Some/Ok by construction)
  • Examples in documentation
  • Early initialization code
// Acceptable: known-good initialization
let regex = Regex::new(r"^\d+$").unwrap();  // compile-time-known pattern

// Acceptable: documented invariant
fn pop_front(&mut self) -> T {
    self.queue.pop_front().expect("pop_front called on empty queue")
}

Why

expect() is preferred over unwrap() because the message documents WHY the value should be present. It's a comment that also appears in the panic output.

When to Use

Triggers:

  • The value is guaranteed present by prior logic (invariant)
  • You're in test code (tests should panic on unexpected errors)
  • The error condition is literally impossible (compile-time constant)

When NOT to Use

Don't use this when:

  • The value might legitimately be None/Err at runtime
  • You're writing library code that callers use in production
  • There's a reasonable recovery path

Anti-pattern

// DON'T: unwrap on user input
let port: u16 = args.get("port").unwrap().parse().unwrap();

// DO: propagate errors to the caller
let port: u16 = args.get("port")
    .ok_or_else(|| ConfigError::Missing("port"))?
    .parse()
    .map_err(|e| ConfigError::Invalid("port", e))?;

Summary: Error Handling Decision Tree

Can this operation fail?
├── NO → Return plain value (no Result)
├── YES → Is failure a BUG or an expected condition?
│   ├── BUG (invariant violation) → panic!/expect()
│   └── EXPECTED → Return Result<T, E>
│       ├── What should E be?
│       │   ├── Single module, one error type → type alias (io::Result)
│       │   ├── Multiple failure modes → Error enum
│       │   ├── Wraps lower-level errors → impl From<Lower> for Mine
│       │   └── Quick prototype → Box<dyn Error> or anyhow::Error
│       └── How to propagate?
│           ├── Same error type → ? operator
│           ├── Need conversion → ? with From impl
│           └── Need context → .map_err() then ?
When you need to... Use
Signal possible failure Result<T, E>
Propagate errors up ? operator
Convert error types for ? impl From<Source> for Target
Add context to an error .map_err(|e| ...)
Match on error variants enum MyError { ... }
Chain error causes Error::source()
Reduce Result<T, MyError> boilerplate type Result<T> = std::result::Result<T, MyError>
Fail on programmer bugs panic!() / expect()
Ignore errors (test/example code only) .unwrap()

See also: