Reading Rust

Part 3 · The compile-time machine II — types & traits

`Result`, `?`, and the error ecosystem

Errors are values, panics are bugs, and the difference is a design decision you will be asked to defend. Plus the `thiserror` vs `anyhow` question, answered.

45 mincontrasting casesPOEbeacon training
What this lesson is trying to break (4)
  • `panic!` is Rust's version of throwing an exception.
  • `?` is just syntactic sugar for early return.
  • `unwrap()` is always bad.
  • You should design a rich error enum for every project.

Rust has two error mechanisms and they are for two different things. Confusing them is one of the clearer signals that someone has not written much Rust.

The split

Varies: who is at fault when it happens

Result<T, E>panic!
RepresentsAn expected conditionA bug
Whose faultNobody’s — the world is like thatThe programmer’s
ExamplesFile missing, bad input, connection refused, parse failureIndex out of bounds, invariant broken, unreachable branch
In the typeYes — visible in the signatureNo — invisible
Caller can handleYes, by matchingNot meaningfully
Default behaviourMust be handled or propagatedUnwind the stack and abort the thread

The test: could a correct caller, using this API properly, ever trigger it? If yes, it is a Result. If no, it is a bug and should panic.

For a JavaScript developer this split is unfamiliar, because throw covers both. JSON.parse throwing on bad input and a TypeError from a null dereference are the same mechanism, caught by the same catch. Rust separates them, and the separation is load-bearing: the signature tells you which failures exist.

For a Go developer it will feel familiar. Result<T, E> is (T, error) with the compiler enforcing that you look at it, and panic! is Go’s panic.

The practical consequence people underestimate:

fn read_config() -> Result<Config, ConfigError> tells you, without reading the body, that this can fail and how. function readConfig(): Config in TypeScript tells you nothing — it may throw four different things.

That is most of what people mean when they say Rust error handling is better. It is not that Result is clever; it is that failure is in the type.

What ? actually does

Predict firstcommit in writing, then look
use std::fs;
use std::num::ParseIntError;

fn read_port() -> Result<u16, ParseIntError> {
    let text = fs::read_to_string("port.txt")?;
    let port = text.trim().parse::<u16>()?;
    Ok(port)
}

Note the two ? operators produce different error types.

The Error trait

pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { None }
}

That is essentially all of it. Debug for developers, Display for users, and source to point at the underlying cause.

source is what makes error chains work. A ConfigError::Io wrapping an io::Error wrapping an OS error lets a reporter walk the chain and print:

Error: failed to load configuration
Caused by:
    0: could not read /etc/app.toml
    1: permission denied (os error 13)

This is why “just use String as your error type” is poor advice beyond a quick script — you lose the chain, and with it the ability to match on what actually went wrong.

thiserror versus anyhow

Both crates are by the same author, and they are not competitors — they are for the two sides of a boundary. Knowing which goes where is a standard question in Rust conversations.

Varies: whether the caller needs to discriminate between failure kinds

thiserroranyhow
Use inLibrariesBinaries, tests, scripts
Gives youA concrete enum you defineOne opaque anyhow::Error
Caller can match on the causeYes — that is the pointOnly by downcasting
Adds contextYou design it in.context("while loading config")
Cost to defineA struct/enum per error domainNothing

The rule: a library does not know what its caller wants to do about a failure, so it must expose the distinction. A binary is the end of the line and usually just reports.

// library
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
    #[error("key not found: {0}")]
    NotFound(String),
    #[error("backend unavailable")]
    Unavailable(#[from] std::io::Error),
}

// binary
fn main() -> anyhow::Result<()> {
    let cfg = load_config().context("loading config")?;
    run(cfg).context("running server")?;
    Ok(())
}

#[from] on a variant generates the From impl, which is what makes ? work without any manual conversion. That single attribute is most of why thiserror exists.

unwrap and expect

unwrap() is not banned. It is a claim: this cannot fail, and if I am wrong that is a bug worth crashing for.

The problem is that unwrap does not say why you believe that. expect does:

let cfg = CONFIG.get().expect("config initialised in main before any handler runs");

A good expect message states the invariant, not the failure. “config initialised in main” tells the next person what assumption was violated. “failed to get config” tells them nothing the panic message did not already say.

That is a genuinely useful review comment and a cheap habit to build.

Where unwrap is fine: tests, prototypes, main, and cases where the invariant is genuinely local and obvious ("42".parse::<u32>().unwrap() on a literal). Where it is not fine: library code, request handlers, anything parsing input from outside the process.

Beaconsee this → think this, without decoding it

#[must_use] · let _ = fallible();

Result is #[must_use], so ignoring one is a warning. Which means let _ = fallible(); is somebody deliberately silencing it.

That is sometimes correct — a best-effort cleanup on a shutdown path, say. It is also where ignored errors hide. When you see it in review, the question is always: is there a comment explaining why this failure does not matter? If not, ask.

Panic behaviour, briefly

By default a panic unwinds: it runs Drop for everything on the stack, then kills the thread. main panicking ends the process.

You can switch to panic = "abort" in Cargo.toml, which skips unwinding entirely — smaller binaries, no unwind tables, and no possibility of catching. Embedded and some server builds do this deliberately.

std::panic::catch_unwind exists but is not a try/catch. It is for FFI boundaries and thread supervisors — unwinding across an FFI boundary is undefined behaviour, so a Rust library called from C must catch at the edge. Using it for ordinary control flow is a misuse, and saying so is a reliable marker of understanding.

Explain it to yourselfwrite it — thinking it does not count

Design the error handling for this, and justify each choice:

A library crate that reads a config file, parses TOML, validates the values, and returns a Config. It is used by three internal services and one CLI.

Decide: thiserror or anyhow? How many error variants? Does validation failure panic or return? What does the CLI do differently from the services?

What you should now be able to say

  • Result is for expected conditions; panic! is for bugs. The test is whether a correct caller could trigger it.
  • ? is match plus From::from — the conversion is the important half.
  • The Error trait is Debug + Display + source(), and source is what makes error chains work.
  • thiserror for libraries (callers must discriminate), anyhow for binaries (the end of the line).
  • expect messages should state the invariant.
  • catch_unwind is for FFI boundaries, not control flow.

Next: 3.6 — Reading generic signatures in the wild, the capstone of Part 3.

In the wildgo read the real thing now, while it is fresh
  • std::error::ErrorNote how small it is — source() and that is essentially it. The entire error ecosystem is built on this one method plus Display. Read the source() docs carefully; error chains are the reason it exists.
  • thiserrorRead the first example only. It is a derive macro that writes the Display and Error impls you would have written by hand. Compare it with the anyhow docs and the library/binary split becomes obvious.
Scheduled for recall4 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.