rust-impl-error-handling
Recoverable errors are values: Result<T, E> flows through the call graph, the ? operator early-returns on Err with an automatic From conversion, and a well-designed error type implements Display + std::error::Error (or core::error::Error in no_std). Panic is reserved for programmer bugs, not for expected failure modes.
Cross-references: [[rust-errors-thiserror-anyhow]] (library vs application error-crate choice), [[rust-errors-runtime]] (panic mechanics, unwind vs abort), [[rust-errors-build-link]] (compile-time error decoding), [[rust-syntax-pattern-matching]] (matching on Result and Option).
When to use this skill
- User writes
.unwrap()or.expect()in library code. - User asks "should this return
ResultorOption". - User asks "why does
?not compile" or getsE0277: the trait From<X> for Y is not implemented. - User designs a new error type and chooses between
enumandstruct. - User asks about
core::error::Errorvsstd::error::Error. - User wants to chain errors with a
source(cause) pointer. - User asks "should I panic here, or return
Result". - User looks for
Resultcombinators (map,map_err,and_then,or_else,ok_or).
Result vs Option: the rule
| Type | Models | Use when |
|---|---|---|
Option<T> |
Presence (Some) or absence (None) of a value. No failure semantics. |
Lookup that may legitimately yield nothing: HashMap::get, Vec::first, optional config. |
Result<T, E> |
Success (Ok) or failure (Err(E)) of an operation, where E describes why it failed. |
Anything that can fail with a reason: I/O, parsing, validation, network. |
ALWAYS return Result<T, E> when the caller needs to know why the operation failed. NEVER return Option<T> to mask an error: the caller loses the cause and cannot recover intelligently.
Convert between them when bridging APIs:
let v: Option<i32> = Some(7);
let r: Result<i32, &str> = v.ok_or("missing"); // Option -> Result
let r2: Result<i32, &str> = v.ok_or_else(|| "missing"); // lazy variant
let r: Result<i32, &str> = Ok(7);
let o: Option<i32> = r.ok(); // Result -> Option (discards Err)
NEVER discard Err via .ok() in library code. Either propagate via ? or transform via .map_err(...).
The ? operator: desugaring and the From chain
expr? on a Result<T, E> desugars (roughly) to:
match expr {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
}
The enclosing function MUST return a Result<_, E2> where E2: From<E>. If the conversion is not available the compiler reports E0277: ? couldn't convert the error: the trait From<E> for E2 is not implemented.
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for MyError {
fn from(e: io::Error) -> Self { MyError::Io(e) }
}
impl From<ParseIntError> for MyError {
fn from(e: ParseIntError) -> Self { MyError::Parse(e) }
}
fn parse_number_from_file(path: &str) -> Result<i32, MyError> {
let s = fs::read_to_string(path)?; // io::Error -> MyError via From
let n: i32 = s.trim().parse()?; // ParseIntError -> MyError via From
Ok(n)
}
ALWAYS provide From<InnerError> for every error type that callers may propagate through ?. NEVER write a match expr { Ok(v)=>v, Err(e)=>return Err(MyError::X(e)) } chain by hand: that is exactly what ? does, and the manual form rots when error variants grow.
The same desugaring applies to Option<T> (None propagates), to ControlFlow, and to any type that implements the unstable Try trait. For stable code, mentally treat ? as Result-or-Option early return + From conversion.
The Error trait: Display + source chain
The std::error::Error trait is the contract for errors:
pub trait Error: Debug + Display {
fn source(&self) -> Option<&(dyn Error + 'static)> { None }
// (provided method; default returns None)
}
Any type that wants to participate in the wider error ecosystem (anyhow, eyre, ? to Box<dyn Error>, error reporters) MUST implement:
Debug(derive it).Display(write the user-facing message, NO trailing newline, NO leading "Error:" prefix).Error(overridesource()to return the underlying cause when wrapping another error).
use std::fmt;
#[derive(Debug)]
struct ConfigError {
path: String,
source: std::io::Error,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "failed to load config {}", self.path)
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
ALWAYS implement source() when your error wraps another error. Downstream tools (anyhow report, eyre's report, tracing::error!) walk the chain via source() to print the full cause.
NEVER cram the cause into the Display message (e.g. format!("config failed: {}", inner)). Report frameworks walk source() and would print the cause twice.
core::error::Error (stabilized 1.81)
Since Rust 1.81 (2024-09-05) the Error trait is also available as core::error::Error, identical signature, usable in no_std. In std crates std::error::Error is a re-export of core::error::Error, so existing code is unaffected.
ALWAYS use core::error::Error if your crate is no_std. NEVER use std::error::Error in a no_std crate: the import will fail to resolve.
#![no_std]
extern crate alloc;
use core::error::Error;
use core::fmt;
#[derive(Debug)]
pub struct ProtocolError;
impl fmt::Display for ProtocolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("protocol violation")
}
}
impl Error for ProtocolError {}
Custom error type design: enum vs struct
| Shape | When |
|---|---|
enum with one variant per distinct failure kind |
Multiple unrelated error sources (I/O, parse, validation). Each variant may carry a payload (the inner error, a path, a span). |
struct with a kind field and a source |
Single conceptual error with rich context (a database error code + the failing query + the cause). |
struct (unit) |
A single, payload-free failure mode. |
// Enum: one variant per kind
#[derive(Debug)]
pub enum LoadError {
Io { path: String, source: std::io::Error },
Parse { line: usize, source: serde_json::Error },
SchemaViolation(String),
}
// Struct: single kind + context
#[derive(Debug)]
pub struct DbError {
pub code: i32,
pub query: String,
pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
}
Design rules:
- ALWAYS keep error types
Send + Sync + 'staticif they may cross thread or async boundaries. Box trait-object causes asBox<dyn Error + Send + Sync + 'static>. - ALWAYS keep the error type small (one machine word per variant payload, boxing large payloads).
Result<T, E>is returned by value; a fatEmakes theOkpath slower. - NEVER expose
Stringas your only error type in a library API: callers cannot match on it. Strings are fine for application binaries. - NEVER return
Box<dyn Error>from a library function: callers lose the ability to match on specific variants. Use a concreteenum(typically viathiserror) and reserveBox<dyn Error>for application glue.
For the derive-macro route (thiserror for libraries, anyhow for binaries) see [[rust-errors-thiserror-anyhow]].
Panic vs Result: the decision
| Failure category | Mechanism | Examples |
|---|---|---|
| Expected, recoverable | Result<T, E> |
File missing, parse failure, validation, network timeout. |
| Programmer bug, unrecoverable | panic! / unwrap / expect |
Index out of bounds in code you control, broken invariant, unreachable!() branch. |
Rust Book Ch 9.3 ("To panic! or Not to panic!") rule: panic only when continuing would violate an invariant or risk corruption. Everything else is Result.
// Library API: return Result for expected failures.
pub fn load_user(id: u64) -> Result<User, LoadError> { /* ... */ }
// Programmer bug: panic.
fn assert_sorted(v: &[i32]) {
debug_assert!(v.windows(2).all(|w| w[0] <= w[1]), "input must be sorted");
}
// Application top-level: unwrap with a message documenting the invariant.
fn main() {
let cfg = load_config().expect("config.toml is required at startup");
}
NEVER use .unwrap() in library code. It turns an expected error into an unrecoverable panic and the caller cannot intercept it. Use ? and let the caller decide.
NEVER use .expect("...") to log a recoverable error: the message goes to stderr only on panic, and the application aborts.
ALWAYS use expect(msg) over unwrap() when you do panic intentionally: the message documents which invariant you are asserting, making the panic transcript actionable.
ALWAYS use assert!, debug_assert!, or unreachable!() for invariant checks. Match arms that should be impossible: unreachable!("variant X cannot occur after step Y").
Result combinators: avoid the match ladder
Result has a rich combinator API. Prefer combinators over multi-line match blocks when the logic is one step:
let r: Result<i32, String> = Ok(7);
let doubled: Result<i32, String> = r.map(|x| x * 2); // transform Ok
let labeled: Result<i32, String> = r.map_err(|e| format!("bad: {e}"));// transform Err
let chained: Result<i32, String> = r.and_then(|x| Ok(x + 1)); // chain Result-returning fn
let recover: Result<i32, String> = r.or_else(|_e| Ok(0)); // recover from Err
let val: i32 = r.unwrap_or(0); // default on Err
let val2: i32 = r.unwrap_or_else(|_e| 0); // lazy default
let val3: i32 = r.unwrap_or_default(); // T: Default
For Option<T> <-> Result<T, E>:
let o: Option<i32> = Some(7);
let r: Result<i32, &str> = o.ok_or("missing");
let r2: Result<i32, &str> = o.ok_or_else(|| "missing");
ALWAYS use map_err to convert an error into your function's error type before ?. Either From (automatic) or map_err (explicit) MUST bridge the inner error type to the outer.
NEVER write .map(|x| Ok(x)) followed by a flatten: that is and_then for Result and is built-in.
NEVER write if let Err(e) = result { return Err(e.into()); } else { result.unwrap() }: that is ?, written badly.
A full method table lives in references/methods.md.
Source chain: walking the cause
When errors wrap other errors, callers walk the source() chain to print the full context. The pattern:
fn print_chain(e: &dyn std::error::Error) {
eprintln!("error: {e}");
let mut cur = e.source();
while let Some(src) = cur {
eprintln!("caused by: {src}");
cur = src.source();
}
}
Inside the error type, return the wrapped error from source():
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source) // <- the wrapped error
}
}
In application code (binary crate) use anyhow::Context::context("loading config") to push a layer on the chain. See [[rust-errors-thiserror-anyhow]].
ALWAYS provide a source() impl when your error variant carries an inner error. NEVER mash the inner error into the Display text: report tools will then duplicate the cause when they walk the chain.
Decision tree: error-handling strategy
Operation can fail?
|
+-- No (only programmer bugs): use assert!/debug_assert!/unreachable!.
|
+-- Yes:
|
+-- Library crate?
| |
| +-- Return Result<T, E> with a concrete `enum` error type (often via thiserror).
| +-- Implement Display + Error (+ source() if wrapping inner errors).
| +-- Implement From<InnerError> for every error you propagate via ?.
| +-- NEVER use anyhow::Error in public API. NEVER unwrap/expect.
|
+-- Binary / application crate?
|
+-- Use anyhow::Result<T> at boundaries; .context("...") at each layer.
+-- main() -> anyhow::Result<()> prints the chain on Err.
+-- At the very top: expect("invariant X") is acceptable for startup-only requirements.
See [[rust-errors-thiserror-anyhow]] for the derive-macro details.
Quick reference table
| Item | One-line rule |
|---|---|
Result<T, E> |
Success or failure with reason. Default for fallible APIs. |
Option<T> |
Presence or absence, no reason. Wrong choice if caller needs to know "why". |
? operator |
Early-return on Err, applies From::from. Function must return Result<_, E2: From<inner>>. |
From impl |
The conversion ? uses. One impl per inner error you propagate. |
Error trait |
Debug + Display + source(). Required for ecosystem participation. |
core::error::Error |
Same trait, since 1.81. Use this in no_std. |
Display |
One-line user-facing message. No newline. No leading "Error:". |
source() |
Returns the underlying cause for chain-walking. |
panic! / unwrap / expect |
Programmer bugs only. NEVER in library code. |
map_err |
Transform an Err value (typically into your function's error type). |
and_then |
Chain a Result-returning function. |
or_else |
Recover from Err by computing a new Result. |
ok_or / ok_or_else |
Option<T> -> Result<T, E>. |
unwrap_or / unwrap_or_default / unwrap_or_else |
Extract T with a fallback. Safe in any code. |
Box<dyn Error + Send + Sync + 'static> |
Trait-object error. Fine inside an error variant, NEVER as public API return. |
Avoid these mistakes
(Full list with WHYs in references/anti-patterns.md.)
.unwrap()or.expect()in library code: turns a recoverableErrinto a process-killing panic. Use?and let the caller decide.- Defining an error type without
Display+Errorimpls: it cannot compose with?, anyhow, eyre, orBox<dyn Error>. Always implement both. - Forgetting
From<InnerError>forMyError:?fails to compile withE0277and you fall back tomatchladders. Always addFromper inner error. - Returning
Box<dyn Error>from a library function: callers lose the ability to match on variants. Return a concreteenum; reserveBox<dyn Error>for application glue. Result<(), ()>: lossy, theErrcarries no information. Use a real error type, even a unit struct with a meaningful name.panic!for expected failure modes (parse error, missing file, network down): callers cannot recover. ReturnResult.- Stuffing the inner error into the
Displaymessage AND returning it fromsource(): report tools print the cause twice. Pick one (alwayssource()). - Using
std::error::Errorin ano_stdcrate (since 1.81): the import does not resolve. Usecore::error::Error. .ok()to discard anErrin library code: silently swallows failures. Either propagate via?or convert viamap_err.- Mixing
anyhow::Errorinto a library's public API: downstream cannot match on variants, your error type is forever opaque. anyhow belongs in binaries.
Reference links
std::result: fullResultAPI.std::option: fullOptionAPI and the conversions toResult.std::error::Error: the trait,source(), downcasting rules.core::error::Error: identical trait, no_std-friendly (stable since 1.81).- Rust Book Ch 9:
Result,?, panic vs Result. - Rust Book Ch 9.3: "To panic! or Not to panic!" decision criteria.
- Rust 1.81.0 release: announcement of
core::error::Errorstabilization. - RFC 0243: the
?operator design.
For deeper drill-downs see:
references/methods.md: complete signatures forResult,Option,Error, and theFrom/?desugaring.references/examples.md: working code for custom enum errors, source chain,no_stderrors, combinator chains.references/anti-patterns.md: 10+ documented anti-patterns with WHY and FIX.