Skip to content

Result, not exceptions

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.

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 })
}

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:

  1. There is no hidden control flow. Errors leave a function only where you see a ? or a return Err(...). No other line can “throw”.
  2. The caller cannot forget. Result is marked #[must_use]. If you call a fallible function and ignore the Result, the compiler warns.
  3. Every error is in the type. There is no “unchecked” error. The only way to fail without a Result is a panic, and a panic is for bugs (see below).

? 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 })
}
  1. Line 1: The return type says: a Config, or a ConfigError. This is the throws clause and the return type in one.
  2. Line 2-5: map_err turns the io::Error into a ConfigError::Io, and keeps the original error as source. Then ? returns it if the read failed.
  3. Line 6: The last expression is the return value. parse already returns Result<Config, ConfigError>, so no ? is needed.
  4. Line 13: ok_or turns the Option from find_map into a Result: no matching line becomes MissingKey.
  5. 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.

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.

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.

? 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”.

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.

  • 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. anyhow in 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.
  • main can return Result: fn main() -> anyhow::Result<()>. Then a failure prints the error chain and exits with a non-zero code.

The task: save a report to a file.

  1. Worselet _ = throws away the io::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)
  2. 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.

  1. What does ? do on a Result?

    Show the answer

    If the value is Err(e), the function returns Err(From::from(e)) at once. If it is Ok(v), the expression has the value v. It is the only way, other than return, that an error leaves a function.

  2. 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.

  3. 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 thiserror enum.

  4. What is the Rust equivalent of an empty catch block?

    Show the answer

    let _ = fallible_call(); or fallible_call().ok();. Both throw the error away without a warning.

  5. 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 }).