Step 06 of 17
Error handling
Handle failure with Result and the ? operator, design custom error types, and choose between thiserror, anyhow and panics.
Rust has no exceptions. A function that can fail says so in its return type, Result<T, E>, and the caller has to deal with it before it can touch the value. That sounds like Go's if err != nil, and the idea is the same, but Rust adds two things that change how it feels: the ? operator, which propagates errors in one character, and the type system, which won't let you forget an error or read a value that isn't there. This lesson covers Result, ?, writing your own error types, the thiserror and anyhow crates, and when panicking is the right call.
Two kinds of failure
Rust separates failures into two kinds.
Recoverable errors are expected in normal operation: a file doesn't exist, input doesn't parse, a network call times out. They're values of type Result<T, E>, and the caller decides what to do.
Unrecoverable errors are bugs: an index out of bounds, an invariant you were sure of turning out false. They panic: the thread unwinds its stack (dropping everything on the way), prints a message, and stops. In a single-threaded program, that ends the process.
The question to ask is "could a correct program hit this?" If yes, return a Result. If it only happens when the code itself is wrong, panicking is fine.
Result
Result is an ordinary enum:
enum Result<T, E> {
Ok(T),
Err(E),
}You handle it like any enum:
use std::fs;
fn main() {
match fs::read_to_string("config.toml") {
Ok(text) => println!("{} bytes", text.len()),
Err(e) => eprintln!("can't read config: {e}"),
}
// Or parse with a fallback:
let port: u16 = "80a".parse().unwrap_or(8080);
println!("{port}");
}Result is marked #[must_use], so ignoring one produces a warning. It shares most of Option's combinators (map, map_err, and_then, unwrap_or_else, ok()), and you convert between them with option.ok_or(err) and result.ok().
The ? operator
Most of the time a function can't handle an error itself, so it passes it up. Written out, that's a match per call:
use std::fs;
use std::io;
fn read_username_verbose() -> Result<String, io::Error> {
let text = match fs::read_to_string("username.txt") {
Ok(t) => t,
Err(e) => return Err(e),
};
Ok(text.trim().to_string())
}The ? operator does exactly this: on Ok(v) it evaluates to v; on Err(e) it returns early from the function with the error.
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let text = fs::read_to_string("username.txt")?;
Ok(text.trim().to_string())
}? also converts the error on the way out: it calls From::from(e) to turn the error into the function's error type. That's what makes it work across libraries with different error types, as you'll see below. It works on Option too, returning None early, in functions that return Option.
main can return a Result. If it returns Err, the program prints the error's Debug form and exits with a non-zero status:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let port: u16 = std::env::var("PORT")?.parse()?;
println!("port {port}");
Ok(())
}Box<dyn Error> means "any error type". Both std::env::VarError and ParseIntError implement Error, and ? boxes either one. It's convenient for small programs and tests, though callers can't easily tell which error happened.
Custom error types
A library's callers often need to react differently to different failures: retry a timeout, return 404 for a missing record, show a validation message. That calls for an error enum:
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum ConfigError {
Missing(String),
InvalidPort(ParseIntError),
OutOfRange(u32),
}
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::InvalidPort(_) => write!(f, "port is not a number"),
ConfigError::OutOfRange(p) => write!(f, "port {p} is out of range"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::InvalidPort(e) => Some(e),
_ => None,
}
}
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::InvalidPort(e)
}
}
pub fn parse_port(raw: Option<&str>) -> Result<u16, ConfigError> {
let raw = raw.ok_or_else(|| ConfigError::Missing("PORT".into()))?;
let n: u32 = raw.trim().parse()?; // ParseIntError -> ConfigError via From
u16::try_from(n).map_err(|_| ConfigError::OutOfRange(n))
}Three pieces make a good error type:
Displaygives a human-readable, lowercase message without trailing punctuation, so errors chain cleanly in logs.std::error::Error, withsource()returning the underlying cause, lets tools walk the chain, like Go'serrors.Unwrap.Fromimplementations let?convert lower-level errors automatically.
Callers can now branch on the variant with match, which is exhaustive, so a new variant forces every caller to decide what to do. Compare that with Go's errors.Is and errors.As, where nothing tells you a new sentinel exists.
thiserror and anyhow
That's a lot of boilerplate, so almost every Rust project uses one or both of two crates (both by the same author, dtolnay).
thiserror derives Display, Error and From for your error types. Use it in libraries, and in the parts of an application whose callers need to match on errors:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing setting {0}")]
Missing(String),
#[error("port is not a number")]
InvalidPort(#[from] std::num::ParseIntError),
#[error("port {0} is out of range")]
OutOfRange(u32),
}That's equivalent to the hand-written version above. #[from] generates the From impl and marks the field as the source.
anyhow gives you one error type, anyhow::Error, that can hold any error plus a chain of context messages. Use it in applications — binaries, CLI tools, the top of a service — where errors are mostly logged or shown, not matched on:
use anyhow::{Context, Result, bail};
fn load_port(path: &str) -> Result<u16> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {path}"))?;
let port: u16 = text.trim().parse().context("parsing port")?;
if port < 1024 {
bail!("port {port} needs root; pick one above 1023");
}
Ok(port)
}
fn main() -> Result<()> {
let port = load_port("port.txt")?;
println!("{port}");
Ok(())
}Error: reading port.txt
Caused by:
No such file or directory (os error 2)context and with_context play the role of Go's fmt.Errorf("doing x: %w", err): each layer adds what it was trying to do. bail! returns early with a formatted error. You can still recover a specific type with err.downcast_ref::<ConfigError>().
cargo add thiserror anyhowWhen to panic
unwrap() and expect() panic on Err or None. They're appropriate when:
- The failure is a bug. You just checked the vector isn't empty, so
v.first().expect("checked non-empty above")can't fail unless the code is wrong. Write the reason inexpect. - The value is a constant you control, such as
"127.0.0.1:8080".parse::<SocketAddr>().unwrap()or a hard-coded regex. - Startup configuration is broken and the program can't do anything useful. Crashing early with a clear message beats limping along.
- Tests and examples, where a panic is a test failure with a stack trace.
They're not appropriate for anything a user, the network, the filesystem or another service can cause. A web server that panics on a malformed request body has a denial-of-service bug.
Panics in a spawned thread or async task stop only that thread or task; the runtime reports it to whoever joins it. Don't use panics for control flow: catch_unwind exists for FFI boundaries and test harnesses, not as try/catch.
Practice
Build a small, testable config loader.
- Create a project and
cargo add thiserror anyhow. - Define
enum SettingsErrorwiththiserror:Missing(&'static str),BadNumber { key: &'static str, source: std::num::ParseIntError }andInvalid(String). - Write
fn parse_settings(input: &str) -> Result<Settings, SettingsError>where input looks like"workers=4\ntimeout_ms=1500".Settingshasworkers: u8(1–64) andtimeout_ms: u64. Both keys are required. - In
main, useanyhowto read the input from a file given on the command line, add context, and print the parsed settings. - Try it with a missing file, a missing key, a non-number and
workers=0. Check each message reads well.
Show solution
use anyhow::Context;
use thiserror::Error;
#[derive(Debug)]
struct Settings {
workers: u8,
timeout_ms: u64,
}
#[derive(Debug, Error)]
enum SettingsError {
#[error("missing required setting {0}")]
Missing(&'static str),
#[error("setting {key} is not a valid number")]
BadNumber { key: &'static str, source: std::num::ParseIntError },
#[error("invalid setting: {0}")]
Invalid(String),
}
fn lookup<'a>(input: &'a str, key: &'static str) -> Result<&'a str, SettingsError> {
input
.lines()
.filter_map(|line| line.split_once('='))
.find(|(k, _)| k.trim() == key)
.map(|(_, v)| v.trim())
.ok_or(SettingsError::Missing(key))
}
fn parse_settings(input: &str) -> Result<Settings, SettingsError> {
let workers: u8 = lookup(input, "workers")?
.parse()
.map_err(|source| SettingsError::BadNumber { key: "workers", source })?;
if !(1..=64).contains(&workers) {
return Err(SettingsError::Invalid(format!("workers must be 1-64, got {workers}")));
}
let timeout_ms = lookup(input, "timeout_ms")?
.parse()
.map_err(|source| SettingsError::BadNumber { key: "timeout_ms", source })?;
Ok(Settings { workers, timeout_ms })
}
fn main() -> anyhow::Result<()> {
let path = std::env::args().nth(1).context("usage: settings <file>")?;
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
let settings = parse_settings(&text).with_context(|| format!("parsing {path}"))?;
println!("{settings:?}");
Ok(())
}A field named source is automatically treated as the error's source by thiserror, so BadNumber chains to the ParseIntError without a #[from] (which wouldn't work here anyway, since the key is extra data). parse_settings returns a typed error, so a test can assert matches!(err, SettingsError::Missing("workers")). main wraps everything with context for a human. The 'a on lookup says the returned &str borrows from input; the lifetimes lesson explains it.
Key takeaways
- Recoverable failures are
Result<T, E>values; bugs panic. Ask whether a correct program could hit the failure. ?returns early onErr(orNone) and converts the error withFrom, replacing amatchper call.- A good error type implements
Display,Error(withsource) andFromfor the errors it wraps. - Use
thiserrorfor typed errors that callers match on, andanyhowwith.context()in applications. unwrap/expectare for invariants, constants, startup and tests — never for input you don't control.
Check yourself
Quiz
0/4 correct · 4 left
1.What does the ? in let n: u32 = s.parse()?; do when parse fails?
2.You're writing a library that parses invoices. Callers need to tell a missing field from a malformed date. What should your functions return?
3.Which is the most reasonable use of expect?
4.What does anyhow's .context("loading config") add to an error?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.