Error Handling Like a Pro 🧰
Lesson 11 taught you Result and ?.
This lesson teaches what the pros actually ship: error types that explain
themselves, convert into each other, and tell the user what to do about it.
Your own error type, by hand 🔨
String errors work, but an enum of everything
that can go wrong is better: callers can match on it, and the compiler
knows every failure mode. Making it a proper citizen takes two traits,
Display (how it reads) and Error (the marker
every error type shares):
use std::fmt;
#[derive(Debug)]
enum ConfigError {
Missing(String),
NotANumber(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Missing(key) => write!(f, "missing setting '{key}'"),
ConfigError::NotANumber(v) => write!(f, "'{v}' is not a number"),
}
}
}
impl std::error::Error for ConfigError {}
fn port_from(line: Option<&str>) -> Result<u16, ConfigError> {
let raw = line.ok_or(ConfigError::Missing("port".to_string()))?;
raw.parse().map_err(|_| ConfigError::NotANumber(raw.to_string()))
}
fn main() {
println!("{:?}", port_from(Some("7878")));
println!("{}", port_from(Some("crab")).unwrap_err());
println!("{}", port_from(None).unwrap_err());
}
Notice the error messages name the actual key and the actual bad value. That is the difference between a log line you curse at and one that fixes itself: an error is a message to a future debugger, probably you.
Mixing error types: Box<dyn Error> 📦
Real functions fail in several unrelated ways: the file read fails with an
io::Error, the parse fails with a ParseIntError.
A trait object (Lesson 15 knowledge!) accepts them all, and ?
converts automatically:
use std::error::Error;
use std::fs;
fn first_line_number(path: &str) -> Result<i64, Box<dyn Error>> {
let text = fs::read_to_string(path)?; // io::Error
let first = text.lines().next().ok_or("file is empty")?; // &str
let n: i64 = first.trim().parse()?; // ParseIntError
Ok(n)
}
fn main() {
fs::write("number.txt", "42\n").expect("demo setup failed");
match first_line_number("number.txt") {
Ok(n) => println!("got {n}"),
Err(e) => println!("error: {e}"),
}
match first_line_number("Cargo.toml") {
Ok(n) => println!("got {n}"),
Err(e) => println!("error: {e}"),
}
}
Three different error types flow through one ? pipeline.
The tradeoff: callers can no longer match on exactly what failed. Which
brings us to the two crates the whole ecosystem settled on.
thiserror: for code others call 📚
That hand-written Display impl was boilerplate, and the
thiserror crate (cargo add thiserror) generates
it from attributes:
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("missing setting '{0}'")]
Missing(String),
#[error("'{0}' is not a number")]
NotANumber(String),
#[error("could not read the config file")]
Io(#[from] std::io::Error),
}
fn main() {
println!("{}", ConfigError::Missing("port".into()));
println!("{}", ConfigError::NotANumber("crab".into()));
let io_err: ConfigError = std::io::Error::other("disk on fire").into();
println!("{io_err}");
}
Same enum, a tenth of the code. The #[from] attribute is the
quiet star: it writes the conversion that lets ? turn an
io::Error into your ConfigError::Io automatically,
while keeping the original error attached as the source underneath.
anyhow: for code only you call 🎒
Applications mostly just want errors to bubble up to main
wearing a good explanation. anyhow
(cargo add anyhow) gives you one flexible error type plus the
killer feature, context:
use anyhow::{Context, Result};
use std::fs;
fn read_port(path: &str) -> Result<u16> {
let text = fs::read_to_string(path)
.with_context(|| format!("could not read {path}"))?;
let port = text
.trim()
.parse()
.context("the port file should contain just a number")?;
Ok(port)
}
fn main() {
fs::write("port.txt", "7878").expect("demo setup failed");
println!("port: {}", read_port("port.txt").expect("should work"));
if let Err(e) = read_port("missing.txt") {
println!("{e:#}");
}
}
port: 7878
could not read missing.txt: No such file or directory (os error 2)
Each context call wraps the error in another layer of story,
and {e:#} prints the whole chain. The rule the ecosystem
converged on: libraries use thiserror (callers need to
match), applications use anyhow (humans need to read).
This site's own Cloudflare Functions follow the application style.
So when do you panic? 💥
| Situation | Reach for |
|---|---|
| The user gave bad input | Result, always. Bad input is normal life. |
| A file, network, or database let you down | Result, with context added. |
| A bug: "this can never happen" happened | panic! or unreachable!. Crashing honestly beats corrupting data. |
| Examples, prototypes, tests | unwrap/expect are fine. Ship code prefers expect("why this is safe"). |
The temperature parser
Write parse_temp(input: &str) -> Result<f64, TempError>
that reads "21C" or "70F" and returns degrees
Celsius. Design a thiserror enum with three variants: empty
input, missing unit, and not-a-number. Every message must include the
offending text. Test it on ["21C", "70F", "", "hotC", "30"].
Reveal solution
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
enum TempError {
#[error("empty input")]
Empty,
#[error("'{0}' needs a unit: end with C or F")]
NoUnit(String),
#[error("'{0}' is not a number")]
BadNumber(String),
}
fn parse_temp(input: &str) -> Result<f64, TempError> {
let s = input.trim();
if s.is_empty() {
return Err(TempError::Empty);
}
let (num, unit) = s.split_at(s.len() - 1);
match unit {
"C" | "c" | "F" | "f" => {
let value: f64 = num
.trim()
.parse()
.map_err(|_| TempError::BadNumber(num.trim().to_string()))?;
if matches!(unit, "F" | "f") {
Ok((value - 32.0) * 5.0 / 9.0)
} else {
Ok(value)
}
}
_ => Err(TempError::NoUnit(s.to_string())),
}
}
fn main() {
for input in ["21C", "70F", "", "hotC", "30"] {
match parse_temp(input) {
Ok(c) => println!("{input:>5} -> {c:.1}°C"),
Err(e) => println!("{input:>5} -> error: {e}"),
}
}
}
Look at the output for "hotC" versus "30":
different mistakes get different, specific advice. That is the whole
craft. Also sneaky: Lesson 19's matches! and or-patterns
doing real work already. 🌡️