Errors in real apps: anyhow and thiserror
Errors showed the by-hand version: an enum, impl Display, impl Error, impl From. Two crates help with this. thiserror generates that code from attributes. Its docs say it “deliberately does not appear in your public API”, so a library can use it without its callers noticing. anyhow is, in its docs’ words, an error type “for easy idiomatic error handling in Rust applications”, and it adds context to errors (a message wrapped around the original error), like wrapping an exception with a cause in Java or C#, or fmt.Errorf("...: %w") in Go.
RememberA rule of thumb: a library defines its own error types (thiserror writes the boilerplate). An application returns anyhow::Result and adds .context(...) as errors travel up.
app-errors/ ├── src/ │ └── main.rs the program on this page ├── Cargo.lock created by Cargo └── Cargo.toml dependencies go here
[package]name = "app-errors"version = "0.1.0"edition = "2024"
[dependencies]anyhow = "1.0.104"thiserror = "2.0.21"use anyhow::Context;use std::fs;use thiserror::Error;
// Library-style error: callers can match on each case.#[derive(Debug, Error)]enum ConfigError { #[error("config file not found: {0}")] Missing(String), #[error("port must be a number, got {0:?}")] BadPort(String),}
fn read_config(path: &str) -> Result<String, ConfigError> { fs::read_to_string(path).map_err(|_| ConfigError::Missing(path.to_string()))}
fn parse_port(text: &str) -> Result<u16, ConfigError> { let text = text.trim(); text.parse() .map_err(|_| ConfigError::BadPort(text.to_string()))}
// Application code: anyhow adds context, like Go's fmt.Errorf("...: %w", err).fn load_port(path: &str) -> anyhow::Result<u16> { let text = read_config(path).context("loading settings")?; let port = parse_port(&text).with_context(|| format!("reading {path}"))?; Ok(port)}
fn main() { fs::write("good.conf", "8080\n").unwrap(); fs::write("bad.conf", "eighty\n").unwrap();
for path in ["good.conf", "bad.conf", "missing.conf"] { match load_port(path) { Ok(port) => println!("{path}: port {port}"), Err(e) => println!("{path}: {e:#}"), // {:#} prints the whole chain } }
// Look for a specific error type held by this anyhow::Error (downcast_ref). if let Err(e) = load_port("bad.conf") { if let Some(ConfigError::BadPort(value)) = e.downcast_ref::<ConfigError>() { println!("the bad value was {value:?}"); } }}good.conf: port 8080 bad.conf: reading bad.conf: port must be a number, got "eighty" missing.conf: loading settings: config file not found: missing.conf the bad value was "eighty"
package main
import ( "errors" "fmt" "os" "strconv" "strings")
type BadPortError struct{ Value string }
func (e *BadPortError) Error() string { return fmt.Sprintf("port must be a number, got %q", e.Value) }
func readConfig(path string) (string, error) { b, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("config file not found: %s", path) } return string(b), nil}
func parsePort(text string) (int, error) { text = strings.TrimSpace(text) n, err := strconv.Atoi(text) if err != nil { return 0, &BadPortError{Value: text} } return n, nil}
func loadPort(path string) (int, error) { text, err := readConfig(path) if err != nil { return 0, fmt.Errorf("loading settings: %w", err) } port, err := parsePort(text) if err != nil { return 0, fmt.Errorf("reading %s: %w", path, err) } return port, nil}
func main() { os.WriteFile("good.conf", []byte("8080\n"), 0o644) os.WriteFile("bad.conf", []byte("eighty\n"), 0o644)
for _, path := range []string{"good.conf", "bad.conf", "missing.conf"} { if port, err := loadPort(path); err != nil { fmt.Printf("%s: %v\n", path, err) } else { fmt.Printf("%s: port %d\n", path, port) } }
_, err := loadPort("bad.conf") var bad *BadPortError if errors.As(err, &bad) { fmt.Printf("the bad value was %q\n", bad.Value) }}good.conf: port 8080 bad.conf: reading bad.conf: port must be a number, got "eighty" missing.conf: loading settings: config file not found: missing.conf the bad value was "eighty"
- thiserror writes the boring parts of an error enum for you: the
Displaymessage (#[error("...")]) and the conversions (#[from], which also makes the field the error’ssource). anyhow::Resultcan hold any error type that implementsstd::error::Errorand isSend + Sync + 'static(Send and Sync explains those bounds), which is the role ofExceptionin Java or C#, or Go’serrorinterface. The docs say it works a lot likeBox<dyn Error>(the lesson’s catch-all).?converts into it..context("...")wraps an error with a message, much like%w. Print with{:#}to see the whole chain on one line: the output above matches the Go program’s.downcast_reflooks for a specific error type inside ananyhow::Error, a bit likeerrors.As. The docs say that after you add context of typeCto an error of typeE, you can still downcast to either, so adding context does not break a downcast that worked before.
In this program read_config throws the io::Error away (map_err(|_| ...)), keeping only the path. The Go version does the same, because its readConfig formats a new error instead of using %w. The first task below keeps the cause.
The same job in your language
Section titled “The same job in your language”I ran a small program for each row in the image behind this page (Java 25, Kotlin 2.4, .NET 10, tsc with --target es2022, Python 3.13, gcc 14, Go 1.27). Each one made a call fail on purpose (parsing "eighty", or fopen on a missing file) and then wrapped or reported the failure with a message.
| Language | Usual choice | Wrapping with a cause |
|---|---|---|
| Go | error values, errors.Is and errors.As |
fmt.Errorf("...: %w", err) (the Go program above) |
| Python | exceptions | raise ... from e, read back as __cause__ |
| TypeScript | throw |
new Error(msg, { cause }), compiled here with --target es2022 |
| Java | exceptions | new RuntimeException(msg, cause), read back with getCause() |
| Kotlin | exceptions, and Result through runCatching |
the same constructor and cause property as Java |
| C# | exceptions | new Exception(msg, inner), read back as InnerException |
| C | return codes and errno |
the run printed only errno=2 and its text, so any context message is built by the caller |
| C++ | exceptions, or std::expected in C++23 |
std::throw_with_nested and std::rethrow_if_nested; std::expected compiled with -std=c++23 |
Try it yourself
Section titled “Try it yourself”I ran all three in the image, as separate programs that replace src/main.rs.
-
Keep the cause. Give the enum a
#[from]variant for each source error, so that?converts for you, and wrap the result withwith_context. In the output, the context comes first, then the enum’s message, then the original error’s text. The downcast still finds theConfigErrorand itsio::Errorinside, andio.kind()printedNotFound:Rustsrc/main.rsuse anyhow::Context;use std::{fs, io, num::ParseIntError};use thiserror::Error;#[derive(Debug, Error)]enum ConfigError {#[error("cannot read the file")]Read(#[from] io::Error),#[error("port is not a number")]Parse(#[from] ParseIntError),}fn read_port(path: &str) -> Result<u16, ConfigError> {let text = fs::read_to_string(path)?; // io::Error -> ConfigError::Readlet port = text.trim().parse()?; // ParseIntError -> ConfigError::ParseOk(port)}fn main() {fs::write("bad.conf", "eighty\n").unwrap();for path in ["bad.conf", "missing.conf"] {let e = read_port(path).with_context(|| format!("loading {path}")).unwrap_err();println!("{e:#}");// The ConfigError is still inside, so a caller can match on it.match e.downcast_ref::<ConfigError>() {Some(ConfigError::Read(io)) => println!(" io error kind: {:?}", io.kind()),Some(ConfigError::Parse(_)) => println!(" not a number"),None => println!(" something else"),}}}$ cargo runloading bad.conf: port is not a number: invalid digit found in string not a number loading missing.conf: cannot read the file: No such file or directory (os error 2) io error kind: NotFound
-
Print one error three ways.
{}printed only the outermost message,{:#}put the causes after it on one line, and{:?}listed them under “Caused by”. Theanyhow::Errordocs say{:?}also includes a backtrace if one was captured. None was captured in this run, because I had not asked for one (see the next task):Rustsrc/main.rsuse anyhow::{Context, Result};use std::fs;fn load(path: &str) -> Result<String> {fs::read_to_string(path).with_context(|| format!("reading {path}"))}fn main() {let e = load("missing.conf").unwrap_err();println!("{{}} : {e}");println!("{{:#}} : {e:#}");println!("{{:?}} :\n{e:?}");println!("links in the chain: {}", e.chain().count());println!("root cause: {}", e.root_cause());}$ cargo run{} : reading missing.conf {:#} : reading missing.conf: No such file or directory (os error 2) {:?} : reading missing.conf Caused by: No such file or directory (os error 2) links in the chain: 2 root cause: No such file or directory (os error 2) -
Return the error from
main. Withfn main() -> Result<()>, anErrends the program withError:and the{:?}form, and exit code 1 (theechoshows it). This is the lesson’smaincan return aResultwithanyhowas the error type:Rustsrc/main.rsuse anyhow::{Context, Result};fn main() -> Result<()> {let port: u16 = "eighty".parse().context("reading the port")?;println!("port {port}");Ok(())}$ cargo run; echo "exit code $?"Error: reading the port Caused by: invalid digit found in string exit code 1The lesson notes that
RUST_BACKTRACE=1adds nothing to aBox<dyn Error>returned frommain. With anyhow it can: the first command counts the lines containing “Stack backtrace” in the output with the variable set, and the second is the control, the same run without it (a debug build, so I print the counts and not the frames):terminal$ RUST_BACKTRACE=1 cargo run 2>&1 | grep -c "Stack backtrace" 1 $ cargo run 2>&1 | grep -c "Stack backtrace" 0
Using ? on an error your function can't hold
Looks fine: in Go it runs fine. Rust won't compile it.
Works in Go. In Rust it's just a rule.No direct equivalent in Kotlin. Just a Rust rule.No direct equivalent in Python. Just a Rust rule.No direct equivalent in TypeScript. Just a Rust rule.Java refuses this too. Same idea, different words.No direct equivalent in C#. Just a Rust rule.No direct equivalent in C. Just a Rust rule.No direct equivalent in C++. Just a Rust rule.use std::fs;use thiserror::Error;
#[derive(Debug, Error)]enum ConfigError { #[error("config file not found: {0}")] Missing(String),}
fn read_config(path: &str) -> Result<String, ConfigError> { let text = fs::read_to_string(path)?; Ok(text)}
fn main() { match read_config("missing.conf") { Ok(text) => println!("{text}"), Err(e) => println!("{e}"), }}package main
import ( "fmt" "os")
func readConfig(path string) (string, error) { b, err := os.ReadFile(path) if err != nil { return "", err // any error value fits the error interface } return string(b), nil}
func main() { if _, err := readConfig("missing.conf"); err != nil { fmt.Println(err) }}open missing.conf: no such file or directory
import java.io.Fileimport java.io.IOException
// Nothing in the signature says this can throw, or what.fun readConfig(path: String): String = File(path).readText()
fun main() { try { println(readConfig("missing.conf")) } catch (e: IOException) { println("caught ${e.javaClass.simpleName}") }}caught FileNotFoundException
Not run here. A Python function does not declare which exceptions it raises, so nothing checks them against a signature.
Not run here. A TypeScript function cannot declare which errors it throws, so nothing checks them against a signature.
import java.nio.file.Files;import java.nio.file.Path;
public class Main { static class ConfigError extends Exception { ConfigError(String message) { super(message); } }
// The signature promises ConfigError, but readString throws IOException. static String readConfig(String path) throws ConfigError { return Files.readString(Path.of(path)); }
public static void main(String[] args) { try { System.out.println(readConfig("missing.conf")); } catch (ConfigError e) { System.out.println(e.getMessage()); } }}Main.java:11: error: unreported exception IOException; must be caught or declared to be thrown
return Files.readString(Path.of(path));
^
1 errorNot run here. C# has no checked exceptions: a method does not declare the exceptions it throws.
Not run here. A C function reports failure through its return value or errno, and nothing converts one kind of error into another for you.
Not run here. A C++ function can throw an exception of any type; its signature does not list them.
What does the compiler say? Guess first, then open.
error[E0277]: `?` couldn't convert the error to `ConfigError` --> src/main.rs:11:40 | 10 | fn read_config(path: &str) -> Result<String, ConfigError> { | --------------------------- expected `ConfigError` because of this 11 | let text = fs::read_to_string(path)?; | ------------------------^ the trait `From<std::io::Error>` is not implemented for `ConfigError` | | | this can't be annotated with `?` because it has type `Result<_, std::io::Error>` | note: `ConfigError` needs to implement `From<std::io::Error>` --> src/main.rs:5:1 | 5 | enum ConfigError { | ^^^^^^^^^^^^^^^^ = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
Go compiles and runs this. The function returns the error interface, which any error value satisfies, so return "", err type-checks.
Kotlin compiles and runs this, and the exception is caught as IOException. A thrown exception travels up the stack, and the signature does not name its type. So there is nothing to convert, and no rule to break.
Not run here. A Python function does not declare which exceptions it raises, so nothing checks them against a signature.
Not run here. A TypeScript function cannot declare which errors it throws, so nothing checks them against a signature.
Java refuses a version of this too: a method that declares throws ConfigError cannot let an IOException escape, and javac says unreported exception IOException; must be caught or declared to be thrown. Java's rule is about checked exceptions; Rust's is about the Result type.
Not run here. C# has no checked exceptions: a method does not declare the exceptions it throws.
Not run here. A C function reports failure through its return value or errno, and nothing converts one kind of error into another for you.
Not run here. A C++ function can throw an exception of any type; its signature does not list them.
This function promises to return a ConfigError. fs::read_to_string fails with an io::Error, and Rust doesn't know how to turn one into the other.
? can convert errors, but only along a route you've written: an impl From<io::Error> for ConfigError, or a #[from] on a variant with thiserror. Or convert by hand with map_err, as the fix below and the program on this page do. A function that returns anyhow::Result accepts any error that implements std::error::Error (and is Send + Sync + 'static), so it needs no conversion.
The fix
Compiles and runs.
use std::fs;use thiserror::Error;
#[derive(Debug, Error)]enum ConfigError { #[error("config file not found: {0}")] Missing(String),}
fn read_config(path: &str) -> Result<String, ConfigError> { fs::read_to_string(path).map_err(|_| ConfigError::Missing(path.to_string()))}
fn main() { match read_config("missing.conf") { Ok(text) => println!("{text}"), Err(e) => println!("{e}"), }}config file not found: missing.conf
Read more
Section titled “Read more”anyhowon docs.rs, with theErrortype (display formats,downcast_ref,chain) and theContexttrait.thiserroron docs.rs:#[error],#[from],#[source]andtransparent.- In this course: Errors, especially Your own error type and Error chains and
source. - The Rust Book: Recoverable errors with
Resultand To panic or not to panic. std::error::Error, includingsource.
The versions this page is about are anyhow 1.0.104 and thiserror 2.0.21, as pinned in Cargo.lock.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.