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.
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! | |
|---|---|---|
| Represents | An expected condition | A bug |
| Whose fault | Nobody’s — the world is like that | The programmer’s |
| Examples | File missing, bad input, connection refused, parse failure | Index out of bounds, invariant broken, unreachable branch |
| In the type | Yes — visible in the signature | No — invisible |
| Caller can handle | Yes, by matching | Not meaningfully |
| Default behaviour | Must be handled or propagated | Unwind 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(): Configin 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
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.
No. fs::read_to_string returns Result<String, std::io::Error>, and there
is no impl From<io::Error> for ParseIntError.
error[E0277]: `?` couldn't convert the error to `ParseIntError`This reveals what ? really is. It is not just early return. It desugars to
roughly:
match expr {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
}That From::from is the whole point. ? converts the error into the function’s
declared error type, which is what lets one function propagate several different
error types — as long as conversions exist.
Three ways to fix it:
// 1. An error type both can convert into
fn read_port() -> Result<u16, Box<dyn std::error::Error>> { /* same body */ }
// 2. Your own enum plus From impls (what thiserror generates)
enum ConfigError { Io(std::io::Error), Parse(ParseIntError) }
impl From<std::io::Error> for ConfigError { /* ... */ }
// 3. anyhow, in a binary
fn read_port() -> anyhow::Result<u16> { /* same body */ }Understanding that ? is From-powered explains a whole category of otherwise
baffling errors, and it explains why adding one From impl can make five
functions compile at once.
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
thiserror | anyhow | |
|---|---|---|
| Use in | Libraries | Binaries, tests, scripts |
| Gives you | A concrete enum you define | One opaque anyhow::Error |
| Caller can match on the cause | Yes — that is the point | Only by downcasting |
| Adds context | You design it in | .context("while loading config") |
| Cost to define | A struct/enum per error domain | Nothing |
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
expectmessage 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.
#[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.
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
Resultis for expected conditions;panic!is for bugs. The test is whether a correct caller could trigger it.?ismatchplusFrom::from— the conversion is the important half.- The
Errortrait isDebug + Display + source(), andsourceis what makes error chains work. thiserrorfor libraries (callers must discriminate),anyhowfor binaries (the end of the line).expectmessages should state the invariant.catch_unwindis for FFI boundaries, not control flow.
Next: 3.6 — Reading generic signatures in the wild, the capstone of Part 3.