Guide contents

Lesson 49 of 94

Custom Error Types

Result & the ? Operator used String and simple enums as errors. Real applications need more: errors that print nicely, carry their underlying cause, convert automatically between layers, and can be inspected by callers. This lesson builds all of that by hand with the standard library, so the next lesson's libraries (thiserror, anyhow) hold no mystery.

Concept Rust
Error type enum AppError { ... } + impl std::error::Error
One case per failure Enum variants per failure
Message Display ({})
Underlying cause Error::source()
Wrapping a lower-level error A variant that holds the lower-level error
Handling a specific error match err { AppError::Io(e) => ... }
Any error type Result<T, Box<dyn Error>>
Checking for a concrete type err.downcast_ref::<NotFound>()

1. Why String errors aren't enough

A String error is readable, but callers can't react to which failure happened without parsing text.

Rust
fn withdraw(balance: u64, amount: u64) -> Result<u64, String> {
    if amount == 0 {
        return Err(String::from("amount must be positive"));
    }
    if amount > balance {
        return Err(format!("insufficient funds: need {amount}, have {balance}"));
    }
    Ok(balance - amount)
}

fn main() {
    match withdraw(100, 250) {
        Ok(left) => println!("left {left}"),
        Err(msg) if msg.starts_with("insufficient") => println!("offer overdraft? ({msg})"), // fragile!
        Err(msg) => println!("error: {msg}"),
    }
}
Output
offer overdraft? (insufficient funds: need 250, have 100)

Matching on message text breaks the day someone rewords the message.

2. An error enum

Each failure becomes a variant, carrying the data the caller might need.

Rust
#[derive(Debug, PartialEq)]
enum WithdrawError {
    ZeroAmount,
    InsufficientFunds { needed: u64, available: u64 },
    AccountLocked,
}

fn withdraw(balance: u64, locked: bool, amount: u64) -> Result<u64, WithdrawError> {
    if locked {
        return Err(WithdrawError::AccountLocked);
    }
    if amount == 0 {
        return Err(WithdrawError::ZeroAmount);
    }
    if amount > balance {
        return Err(WithdrawError::InsufficientFunds { needed: amount, available: balance });
    }
    Ok(balance - amount)
}

fn main() {
    match withdraw(100, false, 250) {
        Ok(left) => println!("left {left}"),
        Err(WithdrawError::InsufficientFunds { needed, available }) => println!("short by {}", needed - available),
        Err(other) => println!("refused: {:?}", other),
    }
    assert_eq!(withdraw(100, true, 5), Err(WithdrawError::AccountLocked));
    println!("locked accounts are refused");
}
Output
short by 150
locked accounts are refused

Deriving PartialEq makes errors easy to check in tests. assert_eq! panics if the two values differ, which is how Rust tests assert (see Testing).

3. Display: a message for humans

Debug output is for developers. Implement Display for the message users and logs should see.

Rust
use std::fmt;

#[derive(Debug)]
enum WithdrawError {
    ZeroAmount,
    InsufficientFunds { needed: u64, available: u64 },
}

impl fmt::Display for WithdrawError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            WithdrawError::ZeroAmount => write!(f, "amount must be greater than zero"),
            WithdrawError::InsufficientFunds { needed, available } => {
                write!(f, "insufficient funds: needed {needed}, available {available}")
            }
        }
    }
}

fn main() {
    let e = WithdrawError::InsufficientFunds { needed: 250, available: 100 };
    println!("Display: {e}");
    println!("Debug:   {:?}", e);
    println!("Display: {}", WithdrawError::ZeroAmount);
}
Output
Display: insufficient funds: needed 250, available 100
Debug:   InsufficientFunds { needed: 250, available: 100 }
Display: amount must be greater than zero

4. Implementing std::error::Error

std::error::Error is the standard trait for error types. It requires Debug and Display, and its methods all have defaults, so an empty impl is enough. Implementing it lets your error work with Box<dyn Error>, error-reporting libraries, and ? conversions.

Rust
use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct ValidationError {
    field: &'static str,
    reason: String,
}

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "invalid {}: {}", self.field, self.reason)
    }
}

impl Error for ValidationError {}

fn check_age(age: i32) -> Result<u8, ValidationError> {
    u8::try_from(age)
        .ok()
        .filter(|a| (13..=120).contains(a))
        .ok_or(ValidationError { field: "age", reason: format!("{age} is not between 13 and 120") })
}

fn main() {
    let err: Box<dyn Error> = Box::new(check_age(7).unwrap_err());
    println!("{err}");
    println!("{:?}", check_age(34));
}
Output
invalid age: 7 is not between 13 and 120
Ok(34)

5. Box<dyn Error>: any error at all

Box<dyn Error> can hold any error type. ? converts any Error into it automatically, so functions that call many different fallible operations become simple.

Rust
use std::error::Error;

fn parse_rect(input: &str) -> Result<(u32, f64), Box<dyn Error>> {
    let (w, ratio) = input.split_once(':').ok_or("expected 'width:ratio'")?;
    let width: u32 = w.trim().parse()?;   // ParseIntError
    let ratio: f64 = ratio.trim().parse()?; // ParseFloatError
    Ok((width, ratio))
}

fn main() {
    for input in ["1920:1.78", "1920", "wide:1.5", "800:tall"] {
        match parse_rect(input) {
            Ok(r) => println!("{input:>10}: {:?}", r),
            Err(e) => println!("{input:>10}: error: {e}"),
        }
    }
}
Output
 1920:1.78: (1920, 1.78)
      1920: error: expected 'width:ratio'
  wide:1.5: error: invalid digit found in string
  800:tall: error: invalid float literal

Even a &str like "expected 'width:ratio'" converts into Box<dyn Error> with ?.

6. main returning Box<dyn Error>

fn main() -> Result<(), Box<dyn Error>> is the standard signature for small programs. An error is printed with its Debug format, and the exit code is 1.

Rustexits with an error
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let workers: u8 = "8".parse()?;
    println!("workers = {workers}");
    let port: u16 = "70000".parse()?;
    println!("port = {port}");
    Ok(())
}
Output
workers = 8
Error: ParseIntError { kind: PosOverflow }

7. From conversions: making ? convert automatically

A custom error enum usually wraps lower-level errors in variants. Implementing From<LowLevel> for YourError lets ? perform the wrapping automatically, as shown in Conversion Traits.

Rust
use std::fmt;
use std::num::{ParseFloatError, ParseIntError};

#[derive(Debug)]
enum ConfigError {
    BadInt(ParseIntError),
    BadFloat(ParseFloatError),
    Missing(&'static str),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::BadInt(e) => write!(f, "invalid integer: {e}"),
            ConfigError::BadFloat(e) => write!(f, "invalid decimal: {e}"),
            ConfigError::Missing(key) => write!(f, "missing setting '{key}'"),
        }
    }
}

impl std::error::Error for ConfigError {}

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadInt(e)
    }
}

impl From<ParseFloatError> for ConfigError {
    fn from(e: ParseFloatError) -> Self {
        ConfigError::BadFloat(e)
    }
}

fn load(workers: Option<&str>, ratio: &str) -> Result<(u32, f64), ConfigError> {
    let w: u32 = workers.ok_or(ConfigError::Missing("workers"))?.parse()?;
    let r: f64 = ratio.parse()?;
    Ok((w, r))
}

fn main() {
    println!("{:?}", load(Some("4"), "0.5"));
    for (w, r) in [(Some("four"), "0.5"), (Some("4"), "half"), (None, "0.5")] {
        println!("{}", load(w, r).unwrap_err());
    }
}
Output
Ok((4, 0.5))
invalid integer: invalid digit found in string
invalid decimal: invalid float literal
missing setting 'workers'

8. source(): exposing the underlying cause

Error::source() returns the error that caused this one. Tools can then walk and print the whole chain.

Rust
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
struct PortError {
    raw: String,
    cause: ParseIntError,
}

impl fmt::Display for PortError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "could not read port from '{}'", self.raw)
    }
}

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

fn parse_port(raw: &str) -> Result<u16, PortError> {
    raw.parse().map_err(|cause| PortError { raw: raw.to_string(), cause })
}

fn main() {
    let err = parse_port("80a").unwrap_err();
    println!("error: {err}");
    if let Some(cause) = err.source() {
        println!("caused by: {cause}");
    }
}
Output
error: could not read port from '80a'
caused by: invalid digit found in string

Don't repeat the cause's message in your own Display. Each layer describes its own step, and the chain tells the whole story.

9. Printing a whole error chain

A helper that follows source() until the end prints every layer.

Rust
use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct Layer {
    message: &'static str,
    cause: Option<Box<dyn Error>>,
}

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

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

fn report(err: &dyn Error) -> String {
    let mut out = format!("error: {err}");
    let mut current = err.source();
    while let Some(cause) = current {
        out.push_str(&format!("\n  caused by: {cause}"));
        current = cause.source();
    }
    out
}

fn main() {
    let root = "abc".parse::<u32>().unwrap_err();
    let middle = Layer { message: "invalid quantity in line 12", cause: Some(Box::new(root)) };
    let top = Layer { message: "could not import orders.csv", cause: Some(Box::new(middle)) };
    println!("{}", report(&top));
}
Output
error: could not import orders.csv
  caused by: invalid quantity in line 12
  caused by: invalid digit found in string

10. Errors for each layer of an application

A service layer wraps repository errors, which wrap parsing errors. Each layer adds meaning, and callers match on the layer they care about.

Rust
use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum RepoError {
    NotFound(u32),
    Corrupt { id: u32, detail: String },
}

#[derive(Debug)]
enum ServiceError {
    Repo(RepoError),
    Forbidden { user: String },
}

impl fmt::Display for RepoError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            RepoError::NotFound(id) => write!(f, "record {id} not found"),
            RepoError::Corrupt { id, detail } => write!(f, "record {id} is corrupt: {detail}"),
        }
    }
}

impl fmt::Display for ServiceError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ServiceError::Repo(_) => write!(f, "storage failure"),
            ServiceError::Forbidden { user } => write!(f, "{user} may not read this document"),
        }
    }
}

impl Error for RepoError {}

impl Error for ServiceError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ServiceError::Repo(e) => Some(e),
            ServiceError::Forbidden { .. } => None,
        }
    }
}

impl From<RepoError> for ServiceError {
    fn from(e: RepoError) -> Self {
        ServiceError::Repo(e)
    }
}

fn load(id: u32) -> Result<String, RepoError> {
    match id {
        1 => Ok(String::from("quarterly plan")),
        2 => Err(RepoError::Corrupt { id, detail: String::from("checksum mismatch") }),
        _ => Err(RepoError::NotFound(id)),
    }
}

fn read_document(user: &str, id: u32) -> Result<String, ServiceError> {
    if user == "guest" {
        return Err(ServiceError::Forbidden { user: user.to_string() });
    }
    Ok(load(id)?) // RepoError -> ServiceError via From
}

fn main() {
    for (user, id) in [("ana", 1), ("ana", 2), ("ana", 9), ("guest", 1)] {
        match read_document(user, id) {
            Ok(doc) => println!("{user}/{id}: {doc}"),
            Err(e) => println!("{user}/{id}: {e}{}", e.source().map(|s| format!(" ({s})")).unwrap_or_default()),
        }
    }
}
Output
ana/1: quarterly plan
ana/2: storage failure (record 2 is corrupt: checksum mismatch)
ana/9: storage failure (record 9 not found)
guest/1: guest may not read this document

11. Recovering from specific errors

Because errors are values, "retry on this error, give up on that one" is just a match.

Rust
#[derive(Debug)]
enum FetchError {
    Timeout,
    RateLimited { retry_after_secs: u64 },
    NotFound,
}

fn fetch(attempt: u32) -> Result<&'static str, FetchError> {
    match attempt {
        1 => Err(FetchError::Timeout),
        2 => Err(FetchError::RateLimited { retry_after_secs: 30 }),
        _ => Ok("payload"),
    }
}

fn main() {
    let mut attempt = 1;
    let mut waited = 0;
    let result = loop {
        match fetch(attempt) {
            Ok(body) => break Ok(body),
            Err(FetchError::Timeout) => println!("attempt {attempt}: timeout, retrying now"),
            Err(FetchError::RateLimited { retry_after_secs }) => {
                println!("attempt {attempt}: rate limited, waiting {retry_after_secs}s");
                waited += retry_after_secs;
            }
            Err(e @ FetchError::NotFound) => break Err(e),
        }
        attempt += 1;
    };
    println!("{:?} after {attempt} attempts, {waited}s waiting", result);
}
Output
attempt 1: timeout, retrying now
attempt 2: rate limited, waiting 30s
Ok("payload") after 3 attempts, 30s waiting

12. Downcasting a Box<dyn Error>

When an error has been boxed, downcast_ref::<T>() recovers the concrete type.

Rust
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
struct QuotaExceeded {
    limit: u32,
}

impl fmt::Display for QuotaExceeded {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "quota of {} requests exceeded", self.limit)
    }
}

impl Error for QuotaExceeded {}

fn handle(input: &str, used: u32) -> Result<u32, Box<dyn Error>> {
    let n: u32 = input.parse()?;
    if used + n > 100 {
        return Err(Box::new(QuotaExceeded { limit: 100 }));
    }
    Ok(used + n)
}

fn main() {
    for (input, used) in [("10", 50), ("x", 50), ("80", 50)] {
        match handle(input, used) {
            Ok(total) => println!("ok, used {total}"),
            Err(e) if e.downcast_ref::<QuotaExceeded>().is_some() => println!("429: {e}"),
            Err(e) if e.downcast_ref::<ParseIntError>().is_some() => println!("400: bad number ({e})"),
            Err(e) => println!("500: {e}"),
        }
    }
}
Output
ok, used 60
400: bad number (invalid digit found in string)
429: quota of 100 requests exceeded

Prefer enums when you control the error type: matching on variants is checked by the compiler, while downcasting can silently stop matching when types change.

13. std::io::Error and ErrorKind

I/O errors carry an ErrorKind you can match on without depending on OS-specific messages.

Rust
use std::fs;
use std::io::ErrorKind;

fn read_config(path: &str) -> Result<String, String> {
    match fs::read_to_string(path) {
        Ok(text) => Ok(text),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(String::from("# defaults")),
        Err(e) => Err(format!("cannot read {path}: {:?}", e.kind())),
    }
}

fn main() {
    println!("{:?}", read_config("/definitely/missing/app.toml"));
    println!("{:?}", read_config("/"));
}
Output
Ok("# defaults")
Err("cannot read /: IsADirectory")

Reading a missing config file falls back to defaults, while reading a directory is still reported as an error.

14. Creating your own io::Error

Code that implements I/O-like operations can return io::Error with a kind and a custom message.

Rust
use std::io;

fn read_header(bytes: &[u8]) -> io::Result<[u8; 4]> {
    bytes
        .get(..4)
        .and_then(|h| h.try_into().ok())
        .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, format!("need 4 header bytes, got {}", bytes.len())))
}

fn main() {
    println!("{:?}", read_header(b"PNG\x0d\x0a"));
    let err = read_header(b"PN").unwrap_err();
    println!("{:?}: {err}", err.kind());
}
Output
Ok([80, 78, 71, 13])
UnexpectedEof: need 4 header bytes, got 2

io::Result<T> is a type alias for Result<T, io::Error>.

15. A type alias for your crate's Result

Libraries commonly define type Result<T> = std::result::Result<T, MyError>; so every signature stays short.

Rust
use std::fmt;

#[derive(Debug)]
enum StoreError {
    Full,
    Duplicate(String),
}

impl fmt::Display for StoreError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            StoreError::Full => write!(f, "store is full"),
            StoreError::Duplicate(k) => write!(f, "key '{k}' already exists"),
        }
    }
}

type Result<T> = std::result::Result<T, StoreError>;

struct Store {
    keys: Vec<String>,
    capacity: usize,
}

impl Store {
    fn add(&mut self, key: &str) -> Result<usize> {
        if self.keys.iter().any(|k| k == key) {
            return Err(StoreError::Duplicate(key.to_string()));
        }
        if self.keys.len() == self.capacity {
            return Err(StoreError::Full);
        }
        self.keys.push(key.to_string());
        Ok(self.keys.len())
    }
}

fn main() {
    let mut s = Store { keys: Vec::new(), capacity: 2 };
    for k in ["a", "b", "a", "c"] {
        match s.add(k) {
            Ok(n) => println!("added {k}, size {n}"),
            Err(e) => println!("cannot add {k}: {e}"),
        }
    }
}
Output
added a, size 1
added b, size 2
cannot add a: key 'a' already exists
cannot add c: store is full

16. Errors that carry context

Wrapping an error with where it happened (a file, a line, a record ID) makes reports actionable.

Rust
use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct LineError {
    line: usize,
    content: String,
    cause: Box<dyn Error>,
}

impl fmt::Display for LineError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "line {}: '{}'", self.line, self.content)
    }
}

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

fn parse_amounts(text: &str) -> Result<Vec<u32>, LineError> {
    text.lines()
        .enumerate()
        .map(|(i, line)| {
            line.trim().parse::<u32>().map_err(|e| LineError { line: i + 1, content: line.to_string(), cause: Box::new(e) })
        })
        .collect()
}

fn main() {
    println!("{:?}", parse_amounts("10\n20\n30"));
    let err = parse_amounts("10\ntwenty\n30").unwrap_err();
    println!("{err}, caused by: {}", err.source().unwrap());
}
Output
Ok([10, 20, 30])
line 2: 'twenty', caused by: invalid digit found in string

17. Collecting every error instead of stopping at the first

Validation forms should report all problems at once. Collect errors into a Vec and return them together.

Rust
use std::fmt;

#[derive(Debug)]
enum FieldError {
    Required(&'static str),
    TooLong { field: &'static str, max: usize },
    Invalid { field: &'static str, reason: &'static str },
}

impl fmt::Display for FieldError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FieldError::Required(field) => write!(f, "{field} is required"),
            FieldError::TooLong { field, max } => write!(f, "{field} must be at most {max} characters"),
            FieldError::Invalid { field, reason } => write!(f, "{field} {reason}"),
        }
    }
}

fn validate(name: &str, email: &str, bio: &str) -> Result<(), Vec<FieldError>> {
    let mut errors = Vec::new();
    if name.trim().is_empty() {
        errors.push(FieldError::Required("name"));
    }
    if !email.contains('@') {
        errors.push(FieldError::Invalid { field: "email", reason: "must contain '@'" });
    }
    if bio.chars().count() > 20 {
        errors.push(FieldError::TooLong { field: "bio", max: 20 });
    }
    if errors.is_empty() { Ok(()) } else { Err(errors) }
}

fn main() {
    println!("{:?}", validate("Ada", "ada@example.com", "mathematician"));
    if let Err(errors) = validate(" ", "ada.example.com", "first computer programmer, 1843") {
        for e in errors {
            println!("- {e}");
        }
    }
}
Output
Ok(())
- name is required
- email must contain '@'
- bio must be at most 20 characters

18. Mapping errors to HTTP status codes

At the edge of an application, domain errors become responses. One match in one place decides each status code.

Rust
use std::fmt;

#[derive(Debug)]
enum ApiError {
    NotFound(String),
    Validation(Vec<String>),
    Conflict(String),
    Internal(String),
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ApiError::NotFound(what) => write!(f, "{what} not found"),
            ApiError::Validation(problems) => write!(f, "validation failed: {}", problems.join("; ")),
            ApiError::Conflict(what) => write!(f, "{what} already exists"),
            ApiError::Internal(_) => write!(f, "internal server error"),
        }
    }
}

impl ApiError {
    fn status(&self) -> u16 {
        match self {
            ApiError::NotFound(_) => 404,
            ApiError::Validation(_) => 422,
            ApiError::Conflict(_) => 409,
            ApiError::Internal(_) => 500,
        }
    }
}

fn respond(result: Result<String, ApiError>) -> String {
    match result {
        Ok(body) => format!("200 {body}"),
        Err(e) => {
            if let ApiError::Internal(detail) = &e {
                eprintln!("log: {detail}"); // details go to logs, not to clients
            }
            format!("{} {{\"error\": \"{e}\"}}", e.status())
        }
    }
}

fn main() {
    println!("{}", respond(Ok(String::from("{\"id\": 7}"))));
    println!("{}", respond(Err(ApiError::NotFound(String::from("user 42")))));
    println!("{}", respond(Err(ApiError::Validation(vec![String::from("email invalid"), String::from("age too low")]))));
    println!("{}", respond(Err(ApiError::Conflict(String::from("username 'ferris'")))));
    println!("{}", respond(Err(ApiError::Internal(String::from("db pool exhausted")))));
}
Output
200 {"id": 7}
404 {"error": "user 42 not found"}
422 {"error": "validation failed: email invalid; age too low"}
409 {"error": "username 'ferris' already exists"}
500 {"error": "internal server error"}

The internal detail is only written to standard error, so it doesn't appear in the output, and never reaches a client.

19. Send + Sync errors for threads and async

Errors that cross threads must be Send + Sync. The common catch-all type for that is Box<dyn Error + Send + Sync>; you'll need it in Concurrency.

Rust
use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync>;

fn parse_all(inputs: &[&str]) -> Result<Vec<i64>, BoxError> {
    inputs.iter().map(|s| s.parse::<i64>().map_err(BoxError::from)).collect()
}

fn main() {
    println!("{:?}", parse_all(&["1", "2", "3"]));
    println!("{}", parse_all(&["1", "x"]).unwrap_err());
}
Output
Ok([1, 2, 3])
invalid digit found in string

20. Realistic example: a configuration loader

The loader reports which file and which key failed, keeps the underlying cause, and lets the caller decide whether to fall back.

Rust
use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum ConfigError {
    MissingKey { file: String, key: &'static str },
    InvalidValue { file: String, key: &'static str, value: String, cause: Box<dyn Error> },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::MissingKey { file, key } => write!(f, "{file}: missing required key '{key}'"),
            ConfigError::InvalidValue { file, key, value, .. } => write!(f, "{file}: invalid value '{value}' for '{key}'"),
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ConfigError::InvalidValue { cause, .. } => Some(cause.as_ref()),
            ConfigError::MissingKey { .. } => None,
        }
    }
}

#[derive(Debug)]
struct Config {
    port: u16,
    timeout_secs: f64,
}

fn get<'a>(text: &'a str, key: &str) -> Option<&'a str> {
    text.lines().find_map(|l| l.split_once('=').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim()))
}

fn load(file: &str, text: &str) -> Result<Config, ConfigError> {
    let raw_port = get(text, "port").ok_or(ConfigError::MissingKey { file: file.to_string(), key: "port" })?;
    let port = raw_port.parse().map_err(|e| ConfigError::InvalidValue {
        file: file.to_string(),
        key: "port",
        value: raw_port.to_string(),
        cause: Box::new(e),
    })?;
    let raw_timeout = get(text, "timeout").unwrap_or("30");
    let timeout_secs = raw_timeout.parse().map_err(|e| ConfigError::InvalidValue {
        file: file.to_string(),
        key: "timeout",
        value: raw_timeout.to_string(),
        cause: Box::new(e),
    })?;
    Ok(Config { port, timeout_secs })
}

fn main() {
    let files = [("prod.conf", "port = 443\ntimeout = 2.5"), ("dev.conf", "timeout = 10"), ("bad.conf", "port = 99999")];
    for (name, text) in files {
        match load(name, text) {
            Ok(c) => println!("{name}: {:?}", c),
            Err(e) => println!("{e}{}", e.source().map(|c| format!(" ({c})")).unwrap_or_default()),
        }
    }
}
Output
prod.conf: Config { port: 443, timeout_secs: 2.5 }
dev.conf: missing required key 'port'
bad.conf: invalid value '99999' for 'port' (number too large to fit in target type)

21. Realistic example: an order service with domain errors

Rust
use std::fmt;

#[derive(Debug, PartialEq)]
enum OrderError {
    EmptyCart,
    OutOfStock { sku: String, requested: u32, available: u32 },
    PaymentDeclined { reason: String },
}

impl fmt::Display for OrderError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            OrderError::EmptyCart => write!(f, "your cart is empty"),
            OrderError::OutOfStock { sku, requested, available } => {
                write!(f, "only {available} of {sku} left (you asked for {requested})")
            }
            OrderError::PaymentDeclined { reason } => write!(f, "payment declined: {reason}"),
        }
    }
}

impl std::error::Error for OrderError {}

fn place_order(cart: &[(&str, u32)], stock: &[(&str, u32)], card_ok: bool) -> Result<u32, OrderError> {
    if cart.is_empty() {
        return Err(OrderError::EmptyCart);
    }
    for &(sku, qty) in cart {
        let available = stock.iter().find(|(s, _)| *s == sku).map(|(_, q)| *q).unwrap_or(0);
        if qty > available {
            return Err(OrderError::OutOfStock { sku: sku.to_string(), requested: qty, available });
        }
    }
    if !card_ok {
        return Err(OrderError::PaymentDeclined { reason: String::from("insufficient funds") });
    }
    Ok(4_812)
}

fn main() {
    let stock = [("KB-1", 5), ("MS-2", 1)];
    let attempts: [(&[(&str, u32)], bool); 4] = [(&[], true), (&[("KB-1", 2), ("MS-2", 3)], true), (&[("KB-1", 1)], false), (&[("KB-1", 1)], true)];
    for (cart, card_ok) in attempts {
        match place_order(cart, &stock, card_ok) {
            Ok(id) => println!("order #{id} placed"),
            Err(e) => println!("sorry, {e}"),
        }
    }
}
Output
sorry, your cart is empty
sorry, only 1 of MS-2 left (you asked for 3)
sorry, payment declined: insufficient funds
order #4812 placed

22. Realistic example: an import job that reports all bad rows

The job imports every valid row and returns the failures with line numbers, instead of failing the whole file.

Rust
use std::fmt;

#[derive(Debug)]
enum RowError {
    WrongFieldCount { found: usize },
    BadNumber { field: &'static str, value: String },
}

impl fmt::Display for RowError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            RowError::WrongFieldCount { found } => write!(f, "expected 3 fields, found {found}"),
            RowError::BadNumber { field, value } => write!(f, "{field} '{value}' is not a number"),
        }
    }
}

#[derive(Debug)]
struct Product {
    sku: String,
    price_cents: u32,
    stock: u32,
}

fn parse_row(line: &str) -> Result<Product, RowError> {
    let fields: Vec<&str> = line.split(',').map(str::trim).collect();
    let [sku, price, stock] = fields.as_slice() else {
        return Err(RowError::WrongFieldCount { found: fields.len() });
    };
    let number = |field: &'static str, v: &str| v.parse::<u32>().map_err(|_| RowError::BadNumber { field, value: v.to_string() });
    Ok(Product { sku: sku.to_string(), price_cents: number("price", price)?, stock: number("stock", stock)? })
}

fn main() {
    let csv = "KB-1, 4999, 10\nMS-2, 19.99, 4\nHD-3, 8999\nWC-4, 2999, lots\nPD-5, 999, 0";
    let mut imported = Vec::new();
    let mut failures = Vec::new();
    for (i, line) in csv.lines().enumerate() {
        match parse_row(line) {
            Ok(p) => imported.push(p),
            Err(e) => failures.push(format!("line {}: {e}", i + 1)),
        }
    }
    println!("imported {:?}", imported.iter().map(|p| &p.sku).collect::<Vec<_>>());
    for f in &failures {
        println!("skipped {f}");
    }
    let stock_value: u64 = imported.iter().map(|p| p.price_cents as u64 * p.stock as u64).sum();
    println!("stock value {:.2}", stock_value as f64 / 100.0);
}
Output
imported ["KB-1", "PD-5"]
skipped line 2: price '19.99' is not a number
skipped line 3: expected 3 fields, found 2
skipped line 4: stock 'lots' is not a number
stock value 499.90

23. Realistic example: a run function and a clean exit

Keep main tiny: call run, print the error chain for humans, and exit with a non-zero code. This gives scripts a reliable exit status and users a readable message instead of a Debug dump.

Rustexits with an error
use std::error::Error;
use std::process::ExitCode;

fn run(args: &[&str]) -> Result<u64, Box<dyn Error>> {
    let [a, b] = args else {
        return Err(format!("expected 2 numbers, got {}", args.len()).into());
    };
    let a: u64 = a.parse()?;
    let b: u64 = b.parse()?;
    a.checked_mul(b).ok_or_else(|| "result overflowed".into())
}

fn main() -> ExitCode {
    for args in [&["6", "7"][..], &["9"], &["3", "x"]] {
        match run(args) {
            Ok(v) => println!("{:?} -> {v}", args),
            Err(e) => {
                eprintln!("error: {e}");
                return ExitCode::FAILURE;
            }
        }
    }
    ExitCode::SUCCESS
}
Output
["6", "7"] -> 42
error: expected 2 numbers, got 1

std::process::ExitCode lets main choose the exit status explicitly. .into() converts a String or &str into a Box<dyn Error>.

24. Realistic example: an error type for a library

A small library exposes one public error type with variants callers can match, a source chain, and conversions for ?, the full pattern this lesson built up.

Rust
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
pub enum SemverError {
    Empty,
    WrongParts(usize),
    BadNumber { part: &'static str, source: ParseIntError },
}

impl fmt::Display for SemverError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            SemverError::Empty => write!(f, "version string is empty"),
            SemverError::WrongParts(n) => write!(f, "expected 3 parts, found {n}"),
            SemverError::BadNumber { part, .. } => write!(f, "{part} version is not a number"),
        }
    }
}

impl Error for SemverError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            SemverError::BadNumber { source, .. } => Some(source),
            _ => None,
        }
    }
}

pub fn parse(v: &str) -> Result<(u64, u64, u64), SemverError> {
    let v = v.trim().trim_start_matches('v');
    if v.is_empty() {
        return Err(SemverError::Empty);
    }
    let parts: Vec<&str> = v.split('.').collect();
    let [major, minor, patch] = parts[..] else {
        return Err(SemverError::WrongParts(parts.len()));
    };
    let num = |part, s: &str| s.parse().map_err(|source| SemverError::BadNumber { part, source });
    Ok((num("major", major)?, num("minor", minor)?, num("patch", patch)?))
}

fn main() {
    for input in ["v1.10.3", "", "1.2", "1.x.3"] {
        match parse(input) {
            Ok(v) => println!("{input:?} -> {:?}", v),
            Err(e) => println!("{input:?} -> {e}{}", e.source().map(|s| format!(": {s}")).unwrap_or_default()),
        }
    }
}
Output
"v1.10.3" -> (1, 10, 3)
"" -> version string is empty
"1.2" -> expected 3 parts, found 2
"1.x.3" -> minor version is not a number: invalid digit found in string

This is exactly the boilerplate the thiserror crate generates for you, as the next lesson shows.

25. Choosing an error strategy

Situation Error type
Quick script, example, prototype Box<dyn Error> (or anyhow, next lesson)
Application code that only reports errors Box<dyn Error> / anyhow::Error with context
Library, or code whose callers react to specific failures A custom enum implementing Error (or thiserror)
Validation with many problems Result<T, Vec<FieldError>>
Bugs and broken invariants panic!, expect, assert!
Rust
fn parse_percentage(s: &str) -> Result<u8, String> {
    let n: u8 = s.trim_end_matches('%').parse().map_err(|_| format!("'{s}' is not a percentage"))?;
    if n > 100 {
        return Err(format!("{n}% is over 100%"));
    }
    Ok(n)
}

fn main() {
    let inputs = ["50%", "150%", "abc"];
    let results: Vec<Result<u8, String>> = inputs.iter().map(|s| parse_percentage(s)).collect();
    println!("{:?}", results);
}
Output
[Ok(50), Err("150% is over 100%"), Err("'abc' is not a percentage")]

Even this small function would benefit from an enum error once a caller needs to tell "over 100" apart from "not a number".

26. A service error as one enum

A Rust service can model its errors as one enum, and the compiler checks that every caller's match handles every case:

Rust
#[derive(Debug)]
enum OrderError {
    OutOfStock { sku: &'static str },
    Payment { code: u16 },
}

fn describe(e: &OrderError) -> String {
    match e {
        OrderError::OutOfStock { sku } => format!("{sku} is sold out"),
        OrderError::Payment { code } => format!("payment failed with code {code}"),
    }
}

fn main() {
    for e in [OrderError::OutOfStock { sku: "KB-1" }, OrderError::Payment { code: 402 }] {
        println!("{}", describe(&e));
    }
}
Output
KB-1 is sold out
payment failed with code 402

Adding a third variant makes describe stop compiling until it handles the new case.

Summary

  • Model failures as an enum with a variant per case, carrying useful data.
  • Implement Display for a human message and std::error::Error so the type works with the whole ecosystem.
  • Override source() to expose the underlying cause, and print chains by walking source().
  • Implement From<LowLevelError> so ? wraps errors automatically.
  • Box<dyn Error> (plus Send + Sync for threads) is the catch-all type for applications; downcast_ref recovers concrete types when needed.
  • Add context (file, line, ID) where errors are created, and map errors to user-facing responses in one place at the edge.

Next: thiserror & anyhow.