Result, not exceptions
In one sentence
Section titled “In one sentence”Errors are values: a function that can fail returns Result<T, E>, which is Ok(value) or Err(error), and ? passes an error to the caller.
The Java you know
Section titled “The Java you know”In Java, a method that can fail throws an exception. Checked exceptions are part of the signature (throws IOException), but unchecked ones (NumberFormatException, IllegalStateException) are not. Any line can throw, and the exception jumps to the nearest matching catch, possibly many frames up.
Java
static Config load(Path path) throws IOException, ConfigException { String text = Files.readString(path); // throws IOException String value = text.lines() .filter(line -> line.startsWith("workers=")) .map(line -> line.substring("workers=".length())) .findFirst() .orElseThrow(() -> new ConfigException("missing key: workers", null)); try { return new Config(Integer.parseInt(value.trim())); // NumberFormatException is unchecked } catch (NumberFormatException e) { throw new ConfigException("workers is not a number", e); }}Rust
pub fn load(path: &Path) -> Result<Config, ConfigError> { let text = fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.to_path_buf(), source, })?; parse(&text)}
pub fn parse(text: &str) -> Result<Config, ConfigError> { let value = text .lines() .find_map(|line| line.strip_prefix("workers=")) .ok_or(ConfigError::MissingKey("workers"))?; let workers = value .trim() .parse() .map_err(|source| ConfigError::BadNumber { key: "workers", source, })?; Ok(Config { workers })}The Rust you will see
Section titled “The Rust you will see”Rust has no exceptions. A function that can fail says so in its return type: Result<Config, ConfigError>. The caller gets either Ok(config) or Err(error), and must decide what to do with it.
This is close to checked exceptions, with three differences:
- There is no hidden control flow. Errors leave a function only where you see a
?or areturn Err(...). No other line can “throw”. - The caller cannot forget.
Resultis marked#[must_use]. If you call a fallible function and ignore theResult, the compiler warns. - Every error is in the type. There is no “unchecked” error. The only way to fail without a
Resultis a panic, and a panic is for bugs (see below).
The ? operator
Section titled “The ? operator”? after an expression means: “if this is Err, return the error from my function now; if it is Ok, give me the value”.
pub fn load(path: &Path) -> Result<Config, ConfigError> { let text = fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.to_path_buf(), source, })?; parse(&text)}
pub fn parse(text: &str) -> Result<Config, ConfigError> { let value = text .lines() .find_map(|line| line.strip_prefix("workers=")) .ok_or(ConfigError::MissingKey("workers"))?; let workers = value .trim() .parse() .map_err(|source| ConfigError::BadNumber { key: "workers", source, })?; Ok(Config { workers })}- Line 1: The return type says: a
Config, or aConfigError. This is thethrowsclause and the return type in one. - Line 2-5:
map_errturns theio::Errorinto aConfigError::Io, and keeps the original error assource. Then?returns it if the read failed. - Line 6: The last expression is the return value.
parsealready returnsResult<Config, ConfigError>, so no?is needed. - Line 13:
ok_orturns theOptionfromfind_mapinto aResult: no matching line becomesMissingKey. - Line 17-20: The same pattern again: convert the
ParseIntError, keep it as the cause, return it with?.
Read ? as “this line can fail, and the error goes up”. When you review a function, the ? marks show you every place where it can return early.
Handling an error: match
Section titled “Handling an error: match”To handle an error, match on the Result. This is the try/catch:
Java
static int workersOrDefault(Path path) { try { return load(path).workers(); } catch (NoSuchFileException e) { return 4; } catch (IOException | ConfigException e) { System.err.println("warning: " + e.getMessage() + "; using 4 workers"); return 4; }}Rust
pub fn workers_or_default(path: &Path) -> u32 { match load(path) { Ok(config) => config.workers, Err(ConfigError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => 4, Err(other) => { eprintln!("warning: {other}; using 4 workers"); 4 } }}The Rust version matches on the variant of the error enum, and on the kind of the inner io::Error, just as the Java version catches by type. If someone adds a new variant to ConfigError, the compiler checks every match that does not have a catch-all arm.
How to read it
Section titled “How to read it”Error types
Section titled “Error types”An error type is a normal type. You see four kinds:
| Error type | Where | Java analogy |
|---|---|---|
A custom enum |
Libraries, and modules with errors that callers handle | Your own exception hierarchy |
A thiserror enum |
The same, with the Display and Error code generated |
The same, with less code |
anyhow::Error |
Applications: main, CLI tools, request handlers that log and return 500 |
catch (Exception e) with a cause chain |
Box<dyn std::error::Error> |
Small programs, examples, prototypes | throws Exception |
A custom error enum has one variant for each failure that a caller might handle differently:
#[derive(Debug)]pub enum ConfigError { Io { path: PathBuf, source: io::Error, }, MissingKey(&'static str), BadNumber { key: &'static str, source: ParseIntError, },}It implements Display (the message) and std::error::Error (with source(), the cause). This is the code that the thiserror crate generates for you:
impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io { path, .. } => write!(f, "cannot read {}", path.display()), Self::MissingKey(key) => write!(f, "missing key `{key}`"), Self::BadNumber { key, .. } => write!(f, "`{key}` is not a whole number"), } }}
impl std::error::Error for ConfigError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io { source, .. } => Some(source), Self::BadNumber { source, .. } => Some(source), Self::MissingKey(_) => None, } }}The usual rule: libraries use typed errors (thiserror), so callers can match on them. Applications use anyhow, and add context with .context("...") or .with_context(|| ...). See gallery entries Read a config file and Error types for a library.
From: how ? converts errors
Section titled “From: how ? converts errors”? does more than return. Before it returns the error, it calls From::from on it. So if your error type implements From<ParseIntError>, ? converts a ParseIntError into your type automatically:
#[derive(Debug, PartialEq)]pub enum QuantityError { NotANumber(ParseIntError), Zero,}
impl From<ParseIntError> for QuantityError { fn from(err: ParseIntError) -> Self { Self::NotANumber(err) }}
pub fn parse_quantity(text: &str) -> Result<u32, QuantityError> { let n: u32 = text.trim().parse()?; // `?` calls `From::from` on the error if n == 0 { return Err(QuantityError::Zero); } Ok(n)}With thiserror, #[from] on a variant generates this From implementation. Read a bare ? as “return the error, converted to my error type”.
panic! is not an exception
Section titled “panic! is not an exception”A panic stops the current thread and unwinds its stack. It is for bugs: states that the program’s own code should never produce. Compare it to an AssertionError, or an IllegalStateException that nobody is meant to catch.
/// `index` comes from our own code, not from input: a wrong value is a bug.pub fn nth_line(lines: &[String], index: usize) -> &str { assert!( index < lines.len(), "nth_line called with {index}, but there are {} lines", lines.len() ); &lines[index]}These macros and methods panic: panic!, assert!, unreachable!, todo!, unwrap(), expect("..."), and indexing out of bounds. They are acceptable:
- in tests and examples;
- for invariants that the code guarantees, with a message that says why:
.expect("the regex is a valid literal"); - at startup, when the program cannot run at all (for example a missing required configuration).
They are not acceptable for input that can be wrong: a file, a request, a user’s typing, a network response. That input is a normal error, and it needs a Result. See S36: panic! used for normal errors and S02: unwrap() / expect() in non-test code.
What good looks like
Section titled “What good looks like”- Fallible functions return
Result. No panics on bad input. ?for propagation, with context added where the error crosses a boundary: which file, which line, which key.- The cause is kept. Error variants hold the original error, and
source()returns it. - Typed errors in libraries, with one variant for each failure that callers handle differently.
anyhowin applications. - Errors are handled once. A function either handles an error (recovers, or logs and continues) or returns it. Not both: log-and-return gives duplicate log lines.
maincan returnResult:fn main() -> anyhow::Result<()>. Then a failure prints the error chain and exits with a non-zero code.
Red flags in AI output
Section titled “Red flags in AI output”unwrap()in library or server code. One bad input stops the thread. See S02:unwrap()/expect()in non-test code.Result<T, String>. The caller can only read the message. See S07:Result<T, String>or stringly-typed errors.map_err(|_| ...). The original error is thrown away, so the cause is lost. See S09: Error context lost (map_err(|_| ...)).let _ = fallible_call();or.ok();that ignores an error. See S08: IgnoredResult(let _ =,.ok();).panic!for expected failures, such as “insufficient funds” or “user not found”. See S36:panic!used for normal errors.- One giant error enum for the whole application, with 30 variants. Every function seems to be able to return every error, so the type tells the caller nothing.
anyhowin a public library API. The library’s callers cannot match on the error without downcasting.Box<dyn Error>everywhere in a service. It works, but callers can only print the error, and the context is often missing.
- S02Severity: High
unwrap()/expect()in non-test codeThe program stops (panics) on a failure that the caller could handle. - S07Severity: Medium
Result<T, String>or stringly-typed errorsThe caller cannot match on the kind of error, only read a message. - S08Severity: HighIgnored
Result(let _ =,.ok();)An error is thrown away silently. - S09Severity: MediumError context lost (
map_err(|_| ...))The original error is replaced, so the cause is no longer available. - S23Severity: High
ascasts that can truncate or change signA numeric conversion that silently produces a wrong value. - S36Severity: High
panic!used for normal errorsAn expected failure stops the program instead of returning an error.
Better vs worse
Section titled “Better vs worse”The task: save a report to a file.
Worse
let _ =throws away theio::Error. When the disk is full, the report is lost, and nobody knows.pub fn save_report(path: &Path, text: &str) {let _ = fs::write(path, text);}Run in Playground (opens the Rust Playground in a new tab) BetterReturns the
io::Result. The caller decides: retry, report, or stop.pub fn save_report(path: &Path, text: &str) -> io::Result<()> {fs::write(path, text)}Run in Playground (opens the Rust Playground in a new tab)
The “worse” version compiles without a warning, because let _ = tells the compiler “I ignore this on purpose”. It is the Rust version of an empty catch block. The agent often adds it to silence the unused_must_use warning. See S08: Ignored Result (let _ =, .ok();) for when it is acceptable.
For larger comparisons, see the gallery: Read a config file (unwrap vs anyhow vs thiserror), Parse numbers from input, and Error types for a library.
Check yourself
Section titled “Check yourself”What does
?do on aResult?Show the answer
If the value is
Err(e), the function returnsErr(From::from(e))at once. If it isOk(v), the expression has the valuev. It is the only way, other thanreturn, that an error leaves a function.Is
unwrap()ever acceptable?Show the answer
Yes: in tests, in examples, and for invariants that the code guarantees, preferably as
expect("why this cannot fail"). Not on input from files, users, or the network.A library function returns
anyhow::Result<Order>. What is the problem?Show the answer
The library’s callers cannot match on the kind of error without a downcast to a concrete type that the signature does not name. A library should return its own error type, for example a
thiserrorenum.What is the Rust equivalent of an empty
catchblock?Show the answer
let _ = fallible_call();orfallible_call().ok();. Both throw the error away without a warning.Why is
map_err(|_| MyError::Io)a smell?Show the answer
The
_throws away the original error. The new error cannot say which file, which operation, or which operating system error it was. Keep it:map_err(|source| MyError::Io { path, source }).