144. Error handling at scale
One function that can fail is easy: return a Result. A crate with a scanner, a compiler, a VM, files and a command line has many kinds of failure. Give them one enum, let From convert, and ? does the plumbing. Then see why neither of this book’s Rust projects quite does that.
- Define an error enum with one variant per kind of failure.
- Implement From so that ? converts each source error into it.
- Map errors to exit codes in one place, as drip-rs does with 65 and 70.
- Compare drip-rs's, engine/rust's and a full error enum's designs.
Many ways to fail
The drip-rs binary can fail in four ways, and it says which with its
exit code: 64 if you call it wrongly, 74 if it can’t read the file, 65
for a compile error, 70 for a runtime error. These are the sysexits.h
codes that clox uses too. A test runner, a shell script or chapter 15’s
differential tests can tell the failures apart without reading a word
of the message.
Inside a program, the same question comes up at every layer. In
chapter 129 one function returned Result<f64, String>. That works
until a caller needs to do something different for different
failures. Then a String is a dead end: you’d have to parse the
message to find out what happened.
A String error is a note that says what went wrong. An error enum is
a form with tick boxes. Anyone can read the note, but only the form can
be sorted into piles without reading it.
One enum for the crate
Say a command-line tool reads a minimum size from a file. Two things can
fail: reading the file (std::io::Error) and parsing the number
(ParseIntError). Give each one a variant, holding the original error:
#[derive(Debug)]
enum CliError {
Io(io::Error),
Num(ParseIntError),
}
A caller can now match on the kind and still see the details inside.
That is chapter 128’s enum doing one more job.
An error enum is a single enum that lists every way a crate’s (or a
module’s) operations can fail, one variant per kind, each holding the
details. Functions return Result<T, ThatEnum>, so callers handle one
error type and can match on its variants.
From, and what ? does with it
The function wants to write both steps with ?:
fn min_size(path: &Path)
-> Result<u32, CliError> {
let s = fs::read_to_string(path)?;
let n = s.trim().parse::<u32>()?;
Ok(n)
}
But read_to_string fails with an io::Error, not a CliError. This
is the detail chapter 129 mentioned in passing: on an Err, ? first
passes the error through From::from, converting it into the
function’s error type, and then returns it[1].
So you tell Rust how to convert:
impl From<io::Error> for CliError {
fn from(e: io::Error) -> Self {
CliError::Io(e)
}
}
and the same for ParseIntError. Step through it:
fn min_size(path: &Path)-> Result<u32, CliError> {let s = fs::read_to_string(path)?;let n = s.trim().parse::<u32>()?;Ok(n)}
From<T> is the standard trait for converting a T into
Self, through fn from(value: T) -> Self. The ? operator calls
it on every error it returns early, so one impl From<SourceError> for MyError per source lets ? work on all of them. Implementing From
also gives you the matching Into for free[2].
Here are all three outcomes. The file named gone doesn’t exist:
use std::fs;
use std::io;
use std::num::ParseIntError;
use std::path::Path;
#[derive(Debug)]
enum CliError {
Io(io::Error),
Num(ParseIntError),
}
impl From<io::Error> for CliError {
fn from(e: io::Error) -> Self {
CliError::Io(e)
}
}
impl From<ParseIntError> for CliError {
fn from(e: ParseIntError) -> Self {
CliError::Num(e)
}
}
fn min_size(path: &Path)
-> Result<u32, CliError> {
let s = fs::read_to_string(path)?;
let n = s.trim().parse::<u32>()?;
Ok(n)
}
fn main() {
let dir = std::env::temp_dir();
let ok = dir.join("ok");
fs::write(&ok, "64\n").unwrap();
let bad = dir.join("bad");
fs::write(&bad, "lots\n").unwrap();
for f in ["ok", "bad", "gone"] {
let r = min_size(&dir.join(f));
let msg = match r {
Ok(n) => n.to_string(),
Err(CliError::Io(e)) => {
format!("io: {e}")
}
Err(CliError::Num(e)) => {
format!("num: {e}")
}
};
println!("{f}: {msg}");
}
}Leave out the ParseIntError impl and the second ? stops compiling
(Rust 1.97, trimmed):
error[E0277]: `?` couldn't convert the
error to `CliError`
= note: the question mark operation
(`?`) implicitly performs a conversion
on the error value using the `From`
trait
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum CliError {
Io(io::Error),
Num(ParseIntError),
}
impl From<io::Error> for CliError {
fn from(e: io::Error) -> Self {
CliError::Io(e)
}
}
// No From<ParseIntError>: E0277.
fn min_size(path: &str)
-> Result<u32, CliError> {
let s = fs::read_to_string(path)?;
let n = s.trim().parse::<u32>()?;
Ok(n)
}
fn main() {
println!("{:?}", min_size("x"));
}Two real designs
Neither of this book’s Rust projects has an error enum for its library, and looking at why is the most useful part of the chapter.
drip-rs splits errors by when they’re reported. The compiler pushes
each message, [line 3] Error at 'x': ..., into a Vec<String> as it
goes, recovering in panic mode to find more (chapter 34). The VM’s
runtime error is this, from vm.rs:
/// Its message and stack trace are
/// already on stderr by the time one
/// of these exists, so it carries
/// nothing.
struct RuntimeError;
type Run<T = ()> = Result<T, RuntimeError>;
runtime_error writes the message and the trace into the VM’s stderr
lines and returns a RuntimeError, and every ? in the run loop passes
it up. interpret then returns an Outcome with the output, the errors
and exit_code, 65 or 70, and main turns that into a process exit
code. A unit struct is enough because nobody upstream needs more than
“stop”.
engine/rust has one error type for compiling, a newtype from
core/src/plugin.rs:
pub struct CompileError(pub String);
impl std::error::Error for CompileError {}
It also implements Display. A syntax error from oxc, an
AoSoA field that isn’t numeric, an internal overlap check: all are one
message. The CLI has the smallest possible error enum, two variants
that both hold a String, because main treats them differently: a
Usage error is followed by the help text, any Other is not
(cli/src/main.rs, trimmed):
enum CliError {
Usage(String),
Other(String),
}
impl From<String> for CliError {
fn from(e: String) -> Self {
CliError::Other(e)
}
}
Its helpers return plain String errors, and that From lets ?
lift them into CliError::Other. Where no From exists, it converts by
hand:
let io = |e: std::io::Error| {
CliError::Other(e.to_string())
};
fs::write(&a.out, text).map_err(io)?;
That is map_err doing what a From<io::Error> impl would do for
?. And in read_profile ? converts on its own: .ok_or("the profile must be a JSON object: …")? works in a function returning a
String error because the standard library has impl From<&str> for String.
The WebAssembly crate of chapter 143 makes the last conversion,
CompileError into JsError, with map_err too.
Both choices fit what the callers do. Every caller of engine/rust’s
compile prints the message and gives up, so a String loses nothing;
the CLI’s two variants exist only because its main does one thing
differently.
drip-rs’s callers need the kind, compile or runtime, and get it as
an exit code. An error enum earns its keep when one crate’s callers want
different things: a test that expects a runtime error and must not
accept a compile error, an editor that underlines compile errors at
their line, a server that retries an I/O error. The exercise sketches
that design for drip, as DripError; it is a design, not code drip-rs
has. Crates such as thiserror generate the Display and From
impls for an error enum[3], but drip-rs
uses only std.
Your turn
A toy compile returns CompileErrors (every error it found), and a toy execute returns a Trap. Make DripError work as the error type of the whole pipeline: write From<CompileErrors> and From<Trap> for it, an exit_code method (65 for Compile, 70 for Runtime, as drip-rs uses), and run, which compiles and then executes, passing either error up with ?.
// From the compiler: every error found.
#[derive(Debug, PartialEq)]
struct CompileErrors(Vec<String>);
// From the VM: the one that stopped it.
#[derive(Debug, PartialEq)]
struct Trap(String);
#[derive(Debug, PartialEq)]
enum DripError {
Compile(Vec<String>),
Runtime(String),
}
fn bad(w: &str) -> String {
let at = format!("Error at '{w}'");
format!("{at}: Expect number.")
}
// A toy compiler: numbers only.
fn compile(src: &str)
-> Result<Vec<f64>, CompileErrors> {
let mut code = Vec::new();
let mut errs = Vec::new();
for w in src.split_whitespace() {
match w.parse::<f64>() {
Ok(n) => code.push(n),
Err(_) => errs.push(bad(w)),
}
}
if errs.is_empty() {
Ok(code)
} else {
Err(CompileErrors(errs))
}
}
// A toy VM with four stack slots.
fn execute(code: &[f64])
-> Result<f64, Trap> {
if code.len() > 4 {
let m = "Stack overflow.";
return Err(Trap(m.to_string()));
}
Ok(code.iter().sum())
}
impl From<CompileErrors> for DripError {
fn from(e: CompileErrors) -> Self {
todo!()
}
}
impl From<Trap> for DripError {
fn from(t: Trap) -> Self {
todo!()
}
}
impl DripError {
fn exit_code(&self) -> u8 {
todo!()
}
}
fn run(src: &str)
-> Result<f64, DripError> {
todo!()
}What you learned
- An error enum gives a crate one error type with a variant per kind of failure, so callers can match instead of parsing messages.
Fromsays how to convert one error into another.?calls it on every early return, so one impl per source error is all the plumbing.- Without a
Fromimpl,?is error E0277;map_errconverts by hand. - drip-rs reports errors as they happen and keeps only the kind: exit
codes 65 and 70. engine/rust’s
CompileErroris oneString. - Choose by what callers need to tell apart.
Check your understanding
0/6- 1.
A function returns
Result<u32, CliError>and callss.parse::<u32>()?, butCliErrorhas noFrom<ParseIntError>. What happens? - 2.
What does
?do withOk(v)? - 3.
Why does drip-rs’s
RuntimeErrorcarry no message? - 4.
engine/rust’s
CompileErroris a singleString, and the CLI’s helpers returnStringerrors too. When is that a reasonable design? - 5.
You write
impl From<Trap> for DripError. Which line now also compiles? - 6.
Which exit code does
drip-rsgive a program with a syntax error?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [2] The Rust Project Developers. Trait core::convert::From (std::convert::From), Rust standard library documentation. Rust standard library API documentation; source in library/core/src/convert/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/convert/trait.From.html (checked 2026-10-31)
- [3] David Tolnay. thiserror: derive(Error) (README). thiserror crate, github.com/dtolnay/thiserror. github.com/dtolnay/thiserror (checked 2026-10-31)