Guide contents

Lesson 51 of 94

Error Handling Patterns

You now know the mechanics: Option, Result, ?, custom error types, thiserror, and anyhow. This lesson is about design: which tool to use where, and the patterns experienced Rust developers rely on to keep error handling simple.

1. Choosing between panic!, Option, and Result

Situation Use Example
A value may legitimately be absent, and there's nothing more to say Option<T> find_user(id) -> Option<User>
An operation can fail in ways the caller may want to handle or report Result<T, E> parse_config(text) -> Result<Config, ConfigError>
The program has a bug: an invariant is broken panic!, expect, assert! An index your own code computed is out of range
Unrecoverable setup failure at startup expect with a clear message, or return Err from main A required environment variable is missing
Rust
fn find_discount(code: &str) -> Option<u8> {
    match code {
        "WELCOME10" => Some(10),
        "VIP25" => Some(25),
        _ => None, // an unknown code is normal, not an error
    }
}

fn apply_discount(cents: u64, percent: u8) -> Result<u64, String> {
    if percent > 90 {
        return Err(format!("{percent}% is above the allowed maximum")); // caller should handle
    }
    Ok(cents - cents * percent as u64 / 100)
}

fn main() {
    let price = 5_000;
    for code in ["VIP25", "BOGUS"] {
        let percent = find_discount(code).unwrap_or(0);
        println!("{code}: {:?}", apply_discount(price, percent));
    }
    println!("{:?}", apply_discount(price, 95));
}
Output
VIP25: Ok(3750)
BOGUS: Ok(5000)
Err("95% is above the allowed maximum")

2. expect messages state the expectation

When a failure would mean a bug, expect documents why it can't happen. A common convention is to phrase the message as what should be true.

Rustpanics at runtime
fn main() {
    let ports = vec![8080, 8081, 8082];
    let first = ports.first().expect("port list is created non-empty above");
    println!("first port {first}");

    let config = "workers=abc";
    let workers: u32 = config
        .strip_prefix("workers=")
        .expect("config always starts with 'workers='")
        .parse()
        .expect("workers should be a number");
    println!("{workers}");
}
Output
first port 8080

thread 'main' panicked at src/main.rs:11:10:
workers should be a number: ParseIntError { kind: InvalidDigit }

When the panic happens, the message points straight at the assumption that turned out to be wrong.

3. Assertions for invariants

assert! and assert_eq! check conditions in every build. debug_assert! checks only in debug builds, for checks too expensive for production. unreachable! marks code that can't run if the logic is right.

Rustpanics at runtime
fn average(values: &[u32]) -> u32 {
    assert!(!values.is_empty(), "average() needs at least one value");
    let sum: u32 = values.iter().sum();
    debug_assert!(sum >= *values.iter().max().unwrap(), "sum can't be smaller than the max");
    sum / values.len() as u32
}

fn quadrant(x: i32, y: i32) -> u8 {
    match (x >= 0, y >= 0) {
        (true, true) => 1,
        (false, true) => 2,
        (false, false) => 3,
        (true, false) => 4,
    }
}

fn weekday_name(n: u8) -> &'static str {
    match n % 7 {
        0 => "Mon", 1 => "Tue", 2 => "Wed", 3 => "Thu", 4 => "Fri", 5 => "Sat", 6 => "Sun",
        _ => unreachable!("n % 7 is always below 7"),
    }
}

fn main() {
    println!("{} {} {}", average(&[10, 20, 30]), quadrant(-2, 5), weekday_name(9));
    println!("{}", average(&[]));
}
Output
20 2 Wed

thread 'main' panicked at src/main.rs:2:5:
average() needs at least one value

4. todo! and unimplemented!

todo!() marks unfinished code; it compiles (with any return type) and panics if reached, which lets you sketch a design and fill it in later.

Rustpanics at runtime
enum Export {
    Csv,
    Json,
    Parquet,
}

fn export(format: Export, rows: &[(&str, u32)]) -> String {
    match format {
        Export::Csv => rows.iter().map(|(k, v)| format!("{k},{v}")).collect::<Vec<_>>().join("\n"),
        Export::Json => format!("{:?}", rows),
        Export::Parquet => todo!("parquet export is planned for v2"),
    }
}

fn main() {
    let rows = [("a", 1), ("b", 2)];
    println!("{}", export(Export::Csv, &rows));
    println!("{}", export(Export::Json, &rows));
    println!("{}", export(Export::Parquet, &rows));
}
Output
a,1
b,2
[("a", 1), ("b", 2)]

thread 'main' panicked at src/main.rs:11:28:
not yet implemented: parquet export is planned for v2

5. Parse, don't validate

Instead of checking a raw String in many places ("is this a valid email?"), parse it once into a type that can only hold valid values. Every function that takes the type can then trust it.

Rust
use std::fmt;

#[derive(Debug, Clone, PartialEq)]
struct Email(String);

impl Email {
    fn parse(raw: &str) -> Result<Email, String> {
        let s = raw.trim().to_lowercase();
        let (local, domain) = s.split_once('@').ok_or_else(|| format!("'{raw}' has no '@'"))?;
        if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
            return Err(format!("'{raw}' is not a valid email"));
        }
        Ok(Email(s))
    }

    fn domain(&self) -> &str {
        self.0.split_once('@').map(|(_, d)| d).unwrap() // safe: guaranteed by parse()
    }
}

impl fmt::Display for Email {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.write_str(&self.0)
    }
}

fn send_welcome(to: &Email) -> String {
    format!("welcome mail queued for {to} (domain {})", to.domain()) // no validation needed here
}

fn main() {
    for raw in [" Ana@Example.com ", "no-at-sign", "x@.com", "ben@corp.io"] {
        match Email::parse(raw) {
            Ok(email) => println!("{}", send_welcome(&email)),
            Err(e) => println!("rejected: {e}"),
        }
    }
}
Output
welcome mail queued for ana@example.com (domain example.com)
rejected: 'no-at-sign' has no '@'
rejected: 'x@.com' is not a valid email
welcome mail queued for ben@corp.io (domain corp.io)

Because the Email field is private in a real module (see Modules), there is no way to construct an invalid one.

6. transpose: swapping Option<Result> and Result<Option>

An optional field that must be valid if present naturally produces an Option<Result<T, E>>. transpose() turns it into a Result<Option<T>, E>, which ? can handle.

Rust
fn parse_optional_port(raw: Option<&str>) -> Result<Option<u16>, std::num::ParseIntError> {
    raw.map(|s| s.parse::<u16>()).transpose()
}

fn main() {
    println!("{:?}", parse_optional_port(None));
    println!("{:?}", parse_optional_port(Some("8080")));
    println!("{:?}", parse_optional_port(Some("eighty")));
}
Output
Ok(None)
Ok(Some(8080))
Err(ParseIntError { kind: InvalidDigit })

7. Stopping at the first error in a loop: try_for_each and try_fold

These iterator methods stop and return the first Err (or None). They're the iterator version of ? inside a for loop.

Rust
fn main() {
    let amounts = ["12", "30", "x", "8"];

    let total = amounts.iter().try_fold(0_u32, |acc, s| s.parse::<u32>().map(|n| acc + n));
    println!("{:?}", total);

    let good = ["12", "30", "8"];
    let total = good.iter().try_fold(0_u32, |acc, s| s.parse::<u32>().map(|n| acc + n));
    println!("{:?}", total);

    let mut sent = Vec::new();
    let result = ["ana@x.com", "ben@x.com", "broken", "cy@x.com"].iter().try_for_each(|to| {
        if !to.contains('@') {
            return Err(format!("stopped at invalid recipient '{to}'"));
        }
        sent.push(*to);
        Ok(())
    });
    println!("{:?}, sent to {:?}", result, sent);
}
Output
Err(ParseIntError { kind: InvalidDigit })
Ok(50)
Err("stopped at invalid recipient 'broken'"), sent to ["ana@x.com", "ben@x.com"]

8. Overflow-safe arithmetic with checked_* and ?

checked_add and friends return Option, which chains with ? in a function returning Option.

Rust
fn order_total(lines: &[(u64, u64)], shipping: u64) -> Option<u64> {
    let mut total: u64 = 0;
    for &(unit_cents, qty) in lines {
        total = total.checked_add(unit_cents.checked_mul(qty)?)?;
    }
    total.checked_add(shipping)
}

fn main() {
    println!("{:?}", order_total(&[(1_999, 2), (500, 3)], 499));
    println!("{:?}", order_total(&[(u64::MAX / 2, 3)], 0));
}
Output
Some(5997)
None

9. Deliberately ignoring an error

Sometimes failure truly doesn't matter, such as a best-effort cache write. Make that decision visible with let _ = ... or .ok(), ideally with a comment, so readers know it isn't an oversight.

Rust
fn warm_cache(key: &str) -> Result<(), String> {
    if key.is_empty() { Err(String::from("empty key")) } else { Ok(()) }
}

fn handle_request(key: &str) -> String {
    // Cache warming is best-effort; the request succeeds either way.
    let _ = warm_cache(key);
    format!("served '{key}'")
}

fn main() {
    println!("{}", handle_request("home"));
    println!("{}", handle_request(""));
    let parsed: Option<u8> = "300".parse().ok(); // "not a valid u8" just becomes None
    println!("{:?}", parsed);
}
Output
served 'home'
served ''
None

10. Partial failures: process everything, report what failed

Batch jobs usually shouldn't stop at the first bad item. Process each item, collect successes and failures, and report a summary.

Rust
#[derive(Debug)]
struct Refund {
    order: u32,
    cents: u64,
}

fn refund(order: u32, cents: u64) -> Result<Refund, String> {
    match order {
        o if o % 5 == 0 => Err(format!("order {o}: already refunded")),
        _ if cents > 50_000 => Err(format!("order {order}: needs manager approval")),
        _ => Ok(Refund { order, cents }),
    }
}

fn main() {
    let requests = [(101, 1_999), (102, 75_000), (105, 500), (106, 4_250)];
    let (ok, failed): (Vec<_>, Vec<_>) = requests.iter().map(|&(o, c)| refund(o, c)).partition(Result::is_ok);
    let refunded: Vec<Refund> = ok.into_iter().map(Result::unwrap).collect();
    let errors: Vec<String> = failed.into_iter().map(Result::unwrap_err).collect();
    let total: u64 = refunded.iter().map(|r| r.cents).sum();
    println!("refunded {} orders ({:.2}): {:?}", refunded.len(), total as f64 / 100.0, refunded.iter().map(|r| r.order).collect::<Vec<_>>());
    for e in &errors {
        println!("  needs attention: {e}");
    }
}
Output
refunded 2 orders (62.49): [101, 106]
  needs attention: order 102: needs manager approval
  needs attention: order 105: already refunded

11. Warnings alongside a result

Some operations succeed but have something to say. Return the warnings together with the value, and keep Err for real failures.

Rust
#[derive(Debug)]
struct Config {
    workers: u32,
    timeout_secs: u32,
}

fn load(workers: u32, timeout_secs: u32) -> Result<(Config, Vec<String>), String> {
    if workers == 0 {
        return Err(String::from("workers must be at least 1"));
    }
    let mut warnings = Vec::new();
    let workers = if workers > 64 {
        warnings.push(format!("workers={workers} capped at 64"));
        64
    } else {
        workers
    };
    if timeout_secs < 5 {
        warnings.push(format!("timeout of {timeout_secs}s is very short"));
    }
    Ok((Config { workers, timeout_secs }, warnings))
}

fn main() {
    for (w, t) in [(8, 30), (200, 2), (0, 30)] {
        match load(w, t) {
            Ok((config, warnings)) => {
                println!("{:?}", config);
                for warning in warnings {
                    println!("  warning: {warning}");
                }
            }
            Err(e) => println!("error: {e}"),
        }
    }
}
Output
Config { workers: 8, timeout_secs: 30 }
Config { workers: 64, timeout_secs: 2 }
  warning: workers=200 capped at 64
  warning: timeout of 2s is very short
error: workers must be at least 1

12. A generic retry helper that keeps the last error

Rust
fn retry<T, E: std::fmt::Display>(attempts: u32, mut op: impl FnMut(u32) -> Result<T, E>) -> Result<T, E> {
    let mut last_err = None;
    for attempt in 1..=attempts {
        match op(attempt) {
            Ok(v) => return Ok(v),
            Err(e) => {
                println!("  attempt {attempt} failed: {e}");
                last_err = Some(e);
            }
        }
    }
    Err(last_err.expect("attempts is at least 1"))
}

fn main() {
    let result = retry(3, |a| if a == 3 { Ok("connected") } else { Err(format!("timeout #{a}")) });
    println!("{:?}", result);
    let result: Result<(), String> = retry(2, |_| Err(String::from("host unreachable")));
    println!("{:?}", result);
}
Output
  attempt 1 failed: timeout #1
  attempt 2 failed: timeout #2
Ok("connected")
  attempt 1 failed: host unreachable
  attempt 2 failed: host unreachable
Err("host unreachable")

13. Fallback chains

Try several sources in order and use the first that works. or_else on Result keeps going only on error.

Rust
fn from_env() -> Result<String, String> {
    Err(String::from("APP_REGION not set"))
}

fn from_file() -> Result<String, String> {
    Err(String::from("config.toml has no region"))
}

fn from_metadata_service() -> Result<String, String> {
    Ok(String::from("eu-west-1"))
}

fn main() {
    let region = from_env()
        .or_else(|e| {
            println!("  {e}, trying config file");
            from_file()
        })
        .or_else(|e| {
            println!("  {e}, asking the metadata service");
            from_metadata_service()
        })
        .unwrap_or_else(|e| {
            println!("  {e}, using default");
            String::from("us-east-1")
        });
    println!("region = {region}");
}
Output
  APP_REGION not set, trying config file
  config.toml has no region, asking the metadata service
region = eu-west-1

14. Errors that can't be returned from Drop

drop returns nothing, so it can't report an error. Types that need a fallible cleanup (flushing a buffer, committing a transaction) offer an explicit method returning Result, and make Drop a safety net that only warns.

Rust
struct BufferedWriter {
    pending: Vec<String>,
    closed: bool,
    fail_on_flush: bool,
}

impl BufferedWriter {
    fn write(&mut self, line: &str) {
        self.pending.push(line.to_string());
    }

    fn close(mut self) -> Result<usize, String> {
        self.closed = true;
        if self.fail_on_flush {
            return Err(format!("disk full while flushing {} lines", self.pending.len()));
        }
        Ok(std::mem::take(&mut self.pending).len())
    }
}

impl Drop for BufferedWriter {
    fn drop(&mut self) {
        if !self.closed && !self.pending.is_empty() {
            println!("warning: writer dropped without close(); {} lines may be lost", self.pending.len());
        }
    }
}

fn main() {
    let mut a = BufferedWriter { pending: Vec::new(), closed: false, fail_on_flush: false };
    a.write("hello");
    println!("{:?}", a.close());

    let mut b = BufferedWriter { pending: Vec::new(), closed: false, fail_on_flush: true };
    b.write("data");
    println!("{:?}", b.close());

    let mut c = BufferedWriter { pending: Vec::new(), closed: false, fail_on_flush: false };
    c.write("forgotten");
} // c is dropped here without close()
Output
Ok(1)
Err("disk full while flushing 1 lines")
warning: writer dropped without close(); 1 lines may be lost

15. Log errors once, at the boundary

Logging an error at every layer prints the same failure several times. Instead, lower layers return errors with context, and only the top-level handler logs them.

Rust
fn parse_amount(raw: &str) -> Result<u64, String> {
    raw.parse().map_err(|e| format!("amount '{raw}': {e}")) // add context, don't log
}

fn process_payment(raw: &str) -> Result<u64, String> {
    let cents = parse_amount(raw).map_err(|e| format!("payment rejected: {e}"))?; // add context, don't log
    Ok(cents)
}

fn handle(raw: &str) -> String {
    match process_payment(raw) {
        Ok(c) => format!("200 charged {c}"),
        Err(e) => {
            println!("[log] {e}"); // the one place that logs
            String::from("400 invalid payment")
        }
    }
}

fn main() {
    println!("{}", handle("1999"));
    println!("{}", handle("19.99"));
}
Output
200 charged 1999
[log] payment rejected: amount '19.99': invalid digit found in string
400 invalid payment

16. Stable error codes for APIs

Human messages change. Clients need stable machine-readable codes. Give each error variant a code that never changes.

Rust
#[derive(Debug)]
enum ApiError {
    InvalidEmail(String),
    EmailTaken(String),
    PasswordTooWeak { min_length: usize },
}

impl ApiError {
    fn code(&self) -> &'static str {
        match self {
            ApiError::InvalidEmail(_) => "E1001",
            ApiError::EmailTaken(_) => "E1002",
            ApiError::PasswordTooWeak { .. } => "E1003",
        }
    }

    fn message(&self) -> String {
        match self {
            ApiError::InvalidEmail(e) => format!("'{e}' is not a valid email address"),
            ApiError::EmailTaken(e) => format!("an account for {e} already exists"),
            ApiError::PasswordTooWeak { min_length } => format!("password must have at least {min_length} characters"),
        }
    }

    fn to_json(&self) -> String {
        format!("{{\"code\":\"{}\",\"message\":\"{}\"}}", self.code(), self.message())
    }
}

fn main() {
    let errors = [ApiError::InvalidEmail(String::from("bob")), ApiError::EmailTaken(String::from("ana@x.com")), ApiError::PasswordTooWeak { min_length: 12 }];
    for e in &errors {
        println!("{}", e.to_json());
    }
}
Output
{"code":"E1001","message":"'bob' is not a valid email address"}
{"code":"E1002","message":"an account for ana@x.com already exists"}
{"code":"E1003","message":"password must have at least 12 characters"}

17. Catching panics at a boundary with catch_unwind

A panic in one request handler shouldn't take down a whole server. std::panic::catch_unwind turns a panic into an Err at a boundary. It's a last line of defense, not a replacement for Result. (Web frameworks and thread pools do this for you.)

Rust
use std::panic;

fn risky_handler(input: &str) -> usize {
    let parts: Vec<&str> = input.split(',').collect();
    parts[2].len() // panics when there are fewer than 3 parts: a bug
}

fn main() {
    panic::set_hook(Box::new(|_| {})); // silence the default panic message for this demo
    for input in ["a,b,c", "a,b"] {
        match panic::catch_unwind(|| risky_handler(input)) {
            Ok(n) => println!("{input}: ok ({n})"),
            Err(_) => println!("{input}: handler crashed, returning 500 and continuing"),
        }
    }
}
Output
a,b,c: ok (1)
a,b: handler crashed, returning 500 and continuing

18. Avoiding nested matches with combinators and ?

Deeply nested match statements are a sign that ?, ok_or, or combinators would read better.

Rust
fn nested(input: &str) -> Result<u32, String> {
    match input.split_once('=') {
        Some((_, v)) => match v.trim().parse::<u32>() {
            Ok(n) => {
                if n > 0 { Ok(n) } else { Err(String::from("must be positive")) }
            }
            Err(e) => Err(e.to_string()),
        },
        None => Err(String::from("missing '='")),
    }
}

fn flat(input: &str) -> Result<u32, String> {
    let (_, v) = input.split_once('=').ok_or("missing '='")?;
    let n: u32 = v.trim().parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
    if n == 0 {
        return Err(String::from("must be positive"));
    }
    Ok(n)
}

fn main() {
    for input in ["retries = 3", "retries = 0", "retries", "retries = x"] {
        println!("{:?} {:?}", nested(input), flat(input));
    }
}
Output
Ok(3) Ok(3)
Err("must be positive") Err("must be positive")
Err("missing '='") Err("missing '='")
Err("invalid digit found in string") Err("invalid digit found in string")

19. Realistic example: parse, then act

A signup flow that turns raw input into validated types first, and then runs logic that can no longer fail on bad input.

Rust
#[derive(Debug)]
struct Username(String);

#[derive(Debug)]
struct Age(u8);

#[derive(Debug)]
struct Signup {
    username: Username,
    age: Age,
}

impl TryFrom<(&str, &str)> for Signup {
    type Error = Vec<String>;

    fn try_from((raw_name, raw_age): (&str, &str)) -> Result<Self, Vec<String>> {
        let mut errors = Vec::new();
        let name = raw_name.trim();
        let username = if name.len() >= 3 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
            Some(Username(name.to_lowercase()))
        } else {
            errors.push(format!("username '{raw_name}' must be 3+ letters, digits or _"));
            None
        };
        let age = match raw_age.trim().parse::<u8>() {
            Ok(a) if a >= 13 => Some(Age(a)),
            Ok(a) => {
                errors.push(format!("age {a} is under 13"));
                None
            }
            Err(_) => {
                errors.push(format!("age '{raw_age}' is not a number"));
                None
            }
        };
        match (username, age) {
            (Some(username), Some(age)) => Ok(Signup { username, age }),
            _ => Err(errors),
        }
    }
}

fn create_account(s: &Signup) -> String {
    format!("created account '{}' (age {})", s.username.0, s.age.0) // no checks needed
}

fn main() {
    for input in [("Ferris_99", "21"), ("x", "abc"), ("ok_name", "9")] {
        match Signup::try_from(input) {
            Ok(s) => println!("{}", create_account(&s)),
            Err(errors) => println!("rejected: {}", errors.join("; ")),
        }
    }
}
Output
created account 'ferris_99' (age 21)
rejected: username 'x' must be 3+ letters, digits or _; age 'abc' is not a number
rejected: age 9 is under 13

20. Realistic example: a resilient batch importer

Retries transient failures, skips permanently bad rows, and ends with a report, the shape of many real ETL jobs.

Rust
#[derive(Debug)]
enum RowError {
    Invalid(String),
    Transient(String),
}

fn save(row: &str, attempt: u32) -> Result<(), RowError> {
    if row.contains("??") {
        return Err(RowError::Invalid(format!("'{row}' is malformed")));
    }
    if row.starts_with("slow") && attempt < 2 {
        return Err(RowError::Transient(String::from("db timeout")));
    }
    Ok(())
}

fn import(rows: &[&str]) -> (usize, Vec<String>) {
    let mut saved = 0;
    let mut problems = Vec::new();
    for (i, row) in rows.iter().enumerate() {
        let mut attempt = 1;
        loop {
            match save(row, attempt) {
                Ok(()) => {
                    saved += 1;
                    break;
                }
                Err(RowError::Transient(e)) if attempt < 3 => {
                    println!("  row {}: {e}, retrying", i + 1);
                    attempt += 1;
                }
                Err(RowError::Transient(e)) => {
                    problems.push(format!("row {}: gave up after {attempt} attempts ({e})", i + 1));
                    break;
                }
                Err(RowError::Invalid(e)) => {
                    problems.push(format!("row {}: skipped, {e}", i + 1));
                    break;
                }
            }
        }
    }
    (saved, problems)
}

fn main() {
    let (saved, problems) = import(&["alpha", "slow-beta", "gam??ma", "delta"]);
    println!("saved {saved} rows");
    for p in problems {
        println!("  {p}");
    }
}
Output
  row 2: db timeout, retrying
saved 3 rows
  row 3: skipped, 'gam??ma' is malformed

21. Realistic example: a checked money transfer

Every precondition is a typed error, arithmetic can't overflow silently, and nothing changes unless every check passes.

Rust
#[derive(Debug, PartialEq)]
enum TransferError {
    SameAccount,
    NonPositive,
    UnknownAccount(u32),
    Insufficient { balance: u64, amount: u64 },
    Overflow,
}

fn transfer(balances: &mut [(u32, u64)], from: u32, to: u32, amount: u64) -> Result<(), TransferError> {
    if from == to {
        return Err(TransferError::SameAccount);
    }
    if amount == 0 {
        return Err(TransferError::NonPositive);
    }
    let fi = balances.iter().position(|(id, _)| *id == from).ok_or(TransferError::UnknownAccount(from))?;
    let ti = balances.iter().position(|(id, _)| *id == to).ok_or(TransferError::UnknownAccount(to))?;
    let new_from = balances[fi].1.checked_sub(amount).ok_or(TransferError::Insufficient { balance: balances[fi].1, amount })?;
    let new_to = balances[ti].1.checked_add(amount).ok_or(TransferError::Overflow)?;
    balances[fi].1 = new_from;
    balances[ti].1 = new_to;
    Ok(())
}

fn main() {
    let mut accounts = vec![(1, 500), (2, 100), (3, u64::MAX - 10)];
    let attempts = [(1, 2, 200), (1, 1, 5), (2, 9, 5), (2, 1, 1_000), (1, 3, 50), (1, 2, 0)];
    for (from, to, amount) in attempts {
        println!("{from} -> {to} ({amount}): {:?}", transfer(&mut accounts, from, to, amount));
    }
    println!("{:?}", &accounts[..2]);
}
Output
1 -> 2 (200): Ok(())
1 -> 1 (5): Err(SameAccount)
2 -> 9 (5): Err(UnknownAccount(9))
2 -> 1 (1000): Err(Insufficient { balance: 300, amount: 1000 })
1 -> 3 (50): Err(Overflow)
1 -> 2 (0): Err(NonPositive)
[(1, 300), (2, 300)]

22. A checklist

  • Is absence normal? Option. Can it fail in a way callers care about? Result. Is it a bug? panic!/expect/assert!.
  • Parse raw input into validated types once, at the edge.
  • Add context where errors are created or crossed (map_err, .context()), and log them once, at the top.
  • Use ?, ok_or, transpose, and try_fold instead of nested match.
  • Decide explicitly between fail-fast (collect into Result) and keep-going (partition and report).
  • Give API errors stable codes; keep internal details out of client responses.
  • Provide explicit close()/commit() methods for cleanup that can fail.
Rust
fn main() {
    let checklist = [
        ("absence is normal", "Option"),
        ("caller may handle failure", "Result"),
        ("broken invariant", "panic!/expect"),
    ];
    for (situation, tool) in checklist {
        println!("{situation:<27} -> {tool}");
    }
}
Output
absence is normal           -> Option
caller may handle failure   -> Result
broken invariant            -> panic!/expect

Summary

  • Use Option for absence, Result for failures callers handle, and panics for bugs; write expect messages that state the assumption.
  • "Parse, don't validate": convert input into types that can only hold valid values.
  • transpose, try_fold, try_for_each, and checked_* with ? keep fallible code flat.
  • Choose fail-fast or keep-going deliberately; return warnings alongside successful results when useful.
  • Retry transient errors, fall back through alternatives with or_else, and keep the last error.
  • Cleanup that can fail belongs in an explicit method, not in Drop.
  • Log errors once at the boundary, with stable codes for API clients.

This completes the Error Handling section. Next: Reading & Writing Files, the start of Files, I/O & the Command Line, where all of this error handling gets put to work.