Errors as Values
Every program deals with two categories of bad news, and most languages give them the same mechanism, which is why error handling is such a mess in practice.
The first category is expected failure. A file might not exist. A user might type letters into a number field. A network might be down. None of these is a defect; they are conditions the program was written to encounter, and handling them is ordinary work that belongs in the design.
The second category is defects. An index past the end of an array. An invariant the code itself broke. An assertion that should have been true. These are not conditions to handle — they are evidence that the program is wrong, and the best response is usually to stop before the damage spreads.
Rust separates them at the language level. Expected failure is a value of type
Result<T, E>, returned and handled like any other value. Defects are panics,
which unwind the stack and end the thread. This chapter is mostly about the
first, and about the fact that putting failure in the return type changes how you
design a program.
Why not exceptions
The case against exceptions is not that they are ugly. It is that they are invisible.
Look at a Java or Python or C# method signature and you cannot tell what it does
when things go wrong. In Java, checked exceptions were an attempt to fix that,
and they failed for a social reason rather than a technical one: the
compiler forced a decision at every call site, the cheapest decision was
catch (Exception e) {} or adding throws to the caller and pushing the problem
up, and within a decade the ecosystem had settled on unchecked exceptions and
convention. Modern Java libraries throw runtime exceptions and document them,
which is to say they do not document them.
The deeper cost is to control flow. Any line in a function with a try block may
be the last one executed, which means the code between two statements is not
guaranteed to run, which means every piece of cleanup has to go in a finally or
be handled by a scope guard. Rust does not need those, because Drop handles
cleanup structurally, and it does not need try to see the branch, because the
branch is in the type.
use std::num::ParseIntError;
fn parse_age(raw: &str) -> Result<u8, ParseIntError> {
raw.trim().parse::<u8>()
}
fn main() {
match parse_age(" 41 ") {
Ok(age) => println!("age {age}"),
Err(error) => println!("bad age: {error}"),
}
}
The signature says this can fail and says what the failure looks like. A reader skimming the module sees which functions are fallible without reading any bodies, and a reviewer can tell whether a new failure mode was added by looking at the diff of the signature.
The question mark
Handling every error with a match would be unbearable, and early Rust was
unbearable for that reason. The ? operator is what makes error-as-value
practical: it unwraps an Ok, and on Err it returns from the enclosing
function with that error, converting it on the way out.
use std::num::ParseIntError;
fn sum_numbers(lines: &str) -> Result<i64, ParseIntError> {
let mut total = 0;
for line in lines.lines() {
let value: i64 = line.trim().parse()?;
total += value;
}
Ok(total)
}
fn main() {
println!("{:?}", sum_numbers("1\n2\n3"));
println!("{:?}", sum_numbers("1\ntwo\n3"));
}
The happy path reads like code with no error handling in it, and the failure path is one character per call. That is the ergonomic trade that makes the whole approach work: the cost of propagating an error is small enough that nobody is tempted to swallow it.
The conversion ? performs is the part worth understanding, because it is the
mechanism the rest of this chapter depends on. When ? returns an error, it
calls From::from on it to convert into the function’s declared error type. So a
function can call three libraries that each fail differently, and as long as its
own error type knows how to be built from each of theirs, ? does the plumbing.
Designing an error type
A module that can fail in several ways wants one enum naming those ways:
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum ConfigError {
Missing { key: String },
NotANumber { key: String, source: ParseIntError },
OutOfRange { key: String, value: i64 },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::Missing { key } => {
write!(f, "required setting {key} is missing")
}
ConfigError::NotANumber { key, .. } => {
write!(f, "setting {key} must be a number")
}
ConfigError::OutOfRange { key, value } => {
write!(f, "setting {key} is {value}, which is out of range")
}
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::NotANumber { source, .. } => Some(source),
_ => None,
}
}
}
fn read_port(settings: &[(&str, &str)]) -> Result<u16, ConfigError> {
let (_, raw) = settings
.iter()
.find(|(key, _)| *key == "port")
.ok_or_else(|| ConfigError::Missing { key: String::from("port") })?;
let value: i64 = raw.trim().parse().map_err(|source| ConfigError::NotANumber {
key: String::from("port"),
source,
})?;
u16::try_from(value).map_err(|_| ConfigError::OutOfRange {
key: String::from("port"),
value,
})
}
fn main() {
println!("{:?}", read_port(&[("port", "8080")]));
for settings in [&[("host", "a")][..], &[("port", "eight")][..], &[("port", "99999")][..]] {
match read_port(settings) {
Ok(port) => println!("port {port}"),
Err(error) => println!("error: {error}"),
}
}
}
Four things in that are deliberate and worth copying.
The variants name failures in the vocabulary of the problem, not of the
implementation. NotANumber tells a caller something actionable; a variant
called ParseError wrapping whatever the parser said tells them to go read the
parser’s documentation.
Each variant carries the context needed to act. Knowing that a setting failed to parse is nearly useless without knowing which setting, and that information is available where the error is created and nowhere else. Errors that lose it get reported as “invalid configuration” and cost somebody an afternoon.
Display writes one line, lowercase, with no trailing period and no “Error:”
prefix, because the caller decides the presentation and will often be printing it
inside a larger sentence. Debug is derived and carries the structure for logs.
source links to the underlying error rather than folding its text into the
message. That gives the caller a chain it can walk — “failed to start server:
setting port must be a number: invalid digit found in string” — with each layer
adding its own context and none of them repeating the layer below.
Note
Writing
Display,Errorand theFromconversions by hand gets repetitive across a dozen error types. Thethiserrorcrate generates all of it from attributes on the enum, and is the standard answer for library error types. It is worth writing one by hand first, as above, so that what the macro generates is familiar rather than magical.
Libraries and applications want different things
This is the design decision people most often get wrong, and the two cases really do pull in opposite directions.
A library does not know what its caller will do with a failure, so it should say precisely what went wrong and let them decide. That means a concrete error enum, exhaustively matchable, with variants that distinguish cases a caller might handle differently. Returning an opaque or stringly-typed error from a library forecloses the caller’s options: they cannot retry only on timeouts, or fall back only when a file is missing, because they cannot tell those apart without parsing your prose.
An application usually has one policy for most failures — log it and exit,
or log it and return a 500 — and the cost of defining a variant for every way a
program can fail is not repaid. There the answer is a type that can hold any
error plus some context. Box<dyn std::error::Error> does this with nothing but
the standard library; the anyhow crate does it with better ergonomics and a
context method, and is what most binaries use.
The line between them is about who decides, and it moves. An application’s core domain logic, where the caller is other code in the same program that genuinely branches on failure, wants library-style typed errors. The outer shell of the same program, where everything ends in a log line, wants the boxed kind.
use std::error::Error;
fn parse_setting(raw: &str) -> Result<i64, Box<dyn Error>> {
let value: i64 = raw.trim().parse()?;
if value < 0 {
return Err("setting must not be negative".into());
}
Ok(value)
}
fn main() -> Result<(), Box<dyn Error>> {
println!("{}", parse_setting(" 42 ")?);
match parse_setting("-1") {
Ok(value) => println!("{value}"),
Err(error) => println!("error: {error}"),
}
Ok(())
}
main can return a Result, and an Err makes the process exit non-zero after
printing the error’s Debug form. For a small tool that is all the error
handling it needs.
Panics are for defects
panic! unwinds the current thread, running destructors as it goes, and by
default ends the process. It is not an exception and should not be used as one:
there is catch_unwind, but it exists for thread boundaries and test harnesses,
and using it for control flow is a way of writing a language other than Rust.
The rule for which mechanism to use is about who is at fault. If the failure came
from the outside world — input, a file, a network, a user — it is expected, and
it is a Result. If the failure means the code is wrong — an index out of
bounds, a None where the invariant said Some, arithmetic that overflowed a
type you chose — it is a panic, and the panic is doing you a favor by stopping
near the mistake.
The corollary matters for library authors. A library that panics on bad input
takes the decision away from its caller, who may have wanted to report the
problem and carry on. A library panics only on defects in its own code, or on
uses that violate a documented contract — slice[10] on a slice of three, for
instance, is a panic because the contract says indices must be in range.
Arithmetic overflow deserves a mention because it is the one that catches people.
In debug builds, integer overflow panics; in release builds, it wraps. Neither is
what you want silently, so when overflow is a real possibility the type’s methods
say what should happen: checked_add returns Option, saturating_add clamps,
wrapping_add wraps on purpose, and overflowing_add reports both.
Context is what makes an error useful
The error that arrives at a log file should let somebody fix the problem without reproducing it. That means each layer adds what it knows, because the layer below does not know it:
use std::fmt;
#[derive(Debug)]
struct StartupError {
stage: &'static str,
detail: String,
}
impl fmt::Display for StartupError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} failed: {}", self.stage, self.detail)
}
}
fn load_settings(path: &str) -> Result<String, StartupError> {
Err(StartupError {
stage: "loading settings",
detail: format!("no such file: {path}"),
})
}
fn main() {
match load_settings("/etc/app/config.toml") {
Ok(settings) => println!("{settings}"),
Err(error) => println!("startup: {error}"),
}
}
An error saying No such file or directory (os error 2) is nearly worthless,
because the one fact needed to act on it — which file — is the fact the operating
system did not include. Adding the path at the layer that knows it is a small
habit with a large return, and it is what anyhow’s .context("reading config")
exists to make convenient.
The opposite failure is worth naming too: an error chain in which every layer restates the layer below in slightly different words produces six lines of log saying the same thing. Add context when you have context to add.
What to avoid
Some patterns show up repeatedly in early Rust and are worth recognizing before you write them.
A String as an error type works and throws away every distinction a caller
could act on. It is acceptable in a prototype and in tests, and it is a
maintenance problem anywhere a caller might branch.
A single catch-all variant, Other(String), that slowly accumulates all the
cases nobody wanted to name, ends in the same place.
unwrap in library code converts a caller’s recoverable situation into a crash
in their process, which they cannot catch and did not agree to.
Swallowing an error with let _ = ... or an empty match arm is the same bug as
an empty catch block, made slightly more visible. Where ignoring really is
correct — a best-effort cleanup, say — write it so the next reader can see it was
a decision, with a comment or an explicit if let Err(e) = ... { log(e) }.
Conclusion
Putting failure in the return type makes it visible in signatures, checkable by
the compiler, and cheap to propagate with ?. That visibility is the point:
error handling stops being an invisible second control flow layered over the
program and becomes part of the program’s design, subject to the same review as
everything else.
The design work is in the error type. A library wants an enum whose variants
distinguish the cases a caller might handle differently, each carrying the
context needed to act, with Display for humans and source for the chain. An
application wants one flexible type and context added at each layer. Panics are
reserved for defects, where stopping near the mistake is the most useful thing
the program can do.
The next chapter turns to traits, which are how a type declares what it can do — including, as it happens, how it reports errors.
Questions to think through
Take a method you have written that throws. Can a caller tell from the signature what it throws, and can they distinguish the failure they want to retry from the one they want to report?
The chapter argues that a library should return typed errors and an application should not. Find a piece of code you own that sits on that line and decide which side it is on. Who consumes its failures?
Pick an error message from a log you have had to debug. What was the one fact that would have made it actionable, and which layer of the program knew it?