Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Result, ?, and Error Enums

Goal: you can build the error architecture every CodeCora service uses — one error enum per crate, Display for humans, From for ? interop, and a stable mapping to outward-facing codes.

Result<T, E> — errors as values

#![allow(unused)]
fn main() {
enum Result<T, E> { Ok(T), Err(E) }
}

No exceptions. Failure is a value in the return type, so a function that can fail announces it in its signature, and the compiler refuses to let you ignore the Err side. That is why Rust services fail gracefully: the handling path is typed, not forgotten.

The ? operator

? means: if this is Err, return the error from the current function; if Ok, unwrap the value. It composes across layers — with one condition: the error types must connect via From.

#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ParseError> {
    let text = std::fs::read_to_string(path)?;   // io::Error -> ParseError via From
    Ok(text)
}
}

The house pattern: one error enum per crate

This is the exact shape of AppError in our Axum services (Sinau, nginjen) — learned on a tiny parser so nothing distracts:

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
enum ParseError {
    Empty,
    MissingField(&'static str),
    BadLevel(String),
}

impl fmt::Display for ParseError {            // human-facing message
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseError::Empty => write!(f, "input is empty"),
            ParseError::MissingField(name) => write!(f, "missing field: {name}"),
            ParseError::BadLevel(tok) => write!(f, "unknown level `{tok}` (want INFO|WARN|ERROR)"),
        }
    }
}

impl std::error::Error for ParseError {}      // interop with the ecosystem

impl From<std::io::Error> for ParseError {    // makes `?` work across the IO boundary
    fn from(e: std::io::Error) -> Self {
        ParseError::MissingField(match e.kind() {
            std::io::ErrorKind::NotFound => "file",
            _ => "readable input",
        })
    }
}
}

Three traits, three jobs:

TraitJobWho consumes it
Debugdeveloper view ({:?})logs, unwrap, test failures
Displayhuman message ({})CLI output, error pages
From<E>conversion for ?every caller crossing a layer

Status codes are a rendering of errors

The mistake to un-learn: sprinkling HTTP codes through business logic. The error type stays transport-agnostic; the boundary renders it:

#![allow(unused)]
fn main() {
impl ParseError {
    fn to_status(&self) -> (&'static str, u16) {
        match self {
            ParseError::Empty => ("422 Unprocessable Entity", 422),
            ParseError::MissingField(_) => ("400 Bad Request", 400),
            ParseError::BadLevel(_) => ("400 Bad Request", 400),
        }
    }
}
}

In an Axum service this method becomes impl IntoResponse for AppError and returns StatusCode — same architecture, bigger stage (M5).

Fail well: the lab’s CLI behavior

$ m2-errlib mixed.log
ok   [INFO] all good
skip 400 Bad Request: unknown level `TRACE` (want INFO|WARN|ERROR)
parsed 1 line(s), skipped 1

Note what the skip line does: names the category (400), quotes the offending token, states the accepted values. Errors are UX. A panic is never UX.

Exercises

  1. Add a LineTooLong(usize) variant (cap: 256 chars) with Display + a 413 status, enforce it in parse_line, extend the tests.
  2. Write fn parse_all(input: &str) -> Result<Vec<Entry>, ParseError> that fails on the FIRST bad line (all-or-nothing), with tests. Contrast with the skip-and-continue CLI. Which policy fits a batch importer? Which fits an HTTP endpoint?
  3. Implement From<ParseError> for String and use it in a map_err chain. Then defend or attack String as an error type (hint: what can BadLevel(String) do that String cannot?).

Done when: cargo test -p m2-errlib is green (6+ tests) and you can explain, in order, what happens when ? hits a mismatched error type.