Rust Error Handling, Demystified
A beginner-friendly conversation on Errors, Results, Options, and beyond.
This is Episode 04

Let's have a beginner-friendly conversation on Errors, Results, Options, and beyond.
Posts
Table of Contents
When and Why to Use anyhow and thiserror crates
Alice: You mentioned external crates like anyhow and thiserror. When should I reach for them?
Bob: Short version:
anyhowin binaries when we don’t need a public, fine-grained error type and just want easy error propagation with context.thiserrorin libraries when we need ergonomic custom error types without writing allimplforDisplay,Error, and conversions.
anyhow - for binaries (mnemonic: Anyhow, Application)
anyhow provides a type called anyhow::Error which is a dynamic error type (like Box<dyn Error> but with some extras such as easy context via .context(...)). It’s great for applications where we just want to bubble errors up to main(), print a nice message with context, and exit. Here is an example:
// ex20.rs
use anyhow::{Context, Result};
use std::fs;
// Result alias = Result<T, anyhow::Error>
fn run() -> Result<()> {
let data = fs::read_to_string("config.json").context("While reading config.json")?; // adds context if it fails
let cfg: serde_json::Value = serde_json::from_str(&data).context("While parsing JSON")?;
println!("Config loaded: {cfg}");
Ok(())
}
fn main() -> Result<()> {
run()
}
Expected output:
Error: While reading config.json
Caused by:
Le fichier spécifié est introuvable. (os error 2)
- Notice how adding
.context(...)makes error messages much more actionable if something fails. - Otherwise, the key point to understand the previous code is to realize that
Resultis a type alias forResult<T, anyhow::Error>.
Alice: OK… But could you show me how we should modify one of the previous code, you know, the one where we were reading JSON config file.
Bob: Ah, yes, you’re right. Let’s rework ex17.rs to see the impact and benefices. Tadaa!:
// ex21.rs
use anyhow::{Context, Result};
use serde::Deserialize;
use std::fs::{read_to_string, write};
use std::io::{self, ErrorKind};
#[derive(Debug, Deserialize)]
struct Config {
app_name: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config> {
let data = read_to_string(path).with_context(|| format!("failed to read config file: {path}"))?;
let cfg = serde_json::from_str::<Config>(&data).with_context(|| format!("failed to parse JSON in: {path}"))?;
Ok(cfg)
}
fn load_or_init(path: &str) -> Result<Config> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(err) => {
if let Some(ioe) = err.downcast_ref::<io::Error>() {
if ioe.kind() == ErrorKind::NotFound {
let default = Config { app_name: "Demo".into(), port: 8086 };
let default_json = r#"{ "app_name": "Demo", "port": 8086 }"#;
write(path, default_json).with_context(|| format!("failed to write default config to {path}"))?;
eprintln!("{path} not found, created with default config");
return Ok(default);
} else {
eprintln!("I/O error accessing {path}: {ioe}");
return Err(err);
}
}
if let Some(parsee) = err.downcast_ref::<serde_json::Error>() {
eprintln!("Invalid JSON in {path}: {parsee}");
return Err(err);
}
Err(err)
}
}
}
fn main() -> Result<()> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#).context("writing good_config.json")?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#).context("writing bad_config.json")?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Expected output of the ex21.rs with bad_config.json:
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: failed to parse JSON in: bad_config.json
Caused by:
invalid type: string "not a number", expected u16 at line 1 column 44
error: process didn't exit successfully: `target\debug\examples\ex21.exe` (exit code: 1)
In VSCode, open ex21.rs and ex17.rs side by side and compare both contents. If you do so and rearrange the source code layout, here is what you should see:

ex17.rs on lhs, ex21.rs on rhs
ex21.rsis shorter but this is not the point.ConfigErrorand its implementations has disappear because it is no longer needed.- Pay attention to
.with_context()inload_or_init().- It is similar to
.context()and the string literals. - It takes a closure that returns a String.
- It is used here to dynamically
format!()string with the value of a variable (path).
- It is similar to
- Also note how the
.context(...)inmain()makes error messages much more actionable.
This is typically what we need in binaries. Ok, let’s read the code:
- In the initial version
ex17.rswe hadfn load_config(path: &str) -> Result<Config, ConfigError> {...} - Now we have
fn load_or_init(path: &str) -> Result<Config> {...}whereResultis a type alias so that the signature should be read asfn load_config(path: &str) -> std::result::Result<Config, anyhow::Error> anyhowimplementFrom<E>for allEthat implementstd::error::Error + Send + Sync + 'static- If any error happen during
read_to_string()then the?operator converts the error fromstd::io::Errortoanyhow::Error(idem forserde_json::Errorfromserde_json::from_str)
Now the tricky part is in load_or_init():
- Its signature should be read as
fn load_or_init(path: &str) -> Result<Config, , anyhow::Error> - On error, we must downcast the
anyhow::Errorand check if it is anio::Error. If it is the case we check if it is anErrorKind::NotFound… - This is not really fun, I agree.
- In fact I wanted to keep the logic of
load_or_init()the same. Since it now receivesResult<Config, , anyhow::Error>and not aResult<Config, ConfigError>we have some work to do to retrieve the 3 kinds of error (not found, access, invalid json). - Concerning
main()except the signature there is no change.
For libraries, we should avoid anyhow::Error in our public API and prefer a concrete error type (possibly made with thiserror) so that downstream users can match on variants. Let’s talk about it now.
thiserror - for libraries
thiserror is a derive macro crate. Instead of manually implementing by hand Display and Error and writing From conversions (remember Debug comes with the directive #[derive(Debug)]), we can do something concise like:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error), // #[from] automatically implements From
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
}
Now our load_config() function can just use the ? operator and the #[from] converts sub-errors automatically. This is excellent for libraries, where we want to expose a stable and descriptive error type to users.
Alice: I really don’t like code snippet. I like to see all the code. ex17.rs is a standalone binary. Could you show me, step by step, how you would split it as a library serving a binary?
Bob: Great idea. It is a good opportunity to see code refactoring in practice. Since you want to see all the code each time, I’ll need some space but this should not be a problem here.
First, let’s review ex17.rs once again:
// ex17.rs
use serde::Deserialize;
use std::fmt;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
#[derive(Debug, Deserialize)]
struct Config {
app_name: String,
port: u16,
}
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(serde_json::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "I/O error: {e}"),
ConfigError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
fn load_or_init(path: &str) -> Result<Config, ConfigError> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#).map_err(ConfigError::Io)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Here is the content of the terminal
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: Parse(Error("invalid type: string \"not a number\", expected u16", line: 1, column: 44))
error: process didn't exit successfully: `target\debug\examples\ex17.exe` (exit code: 1)
As you say, it is a standalone, all-included, kind of binary. So, as a first step, let’s split it into a library and a binary. For demo purpose, we can do this with a single file. In ex22.rs (see below) we just define a module inside the source code. If needed, review what we did in ex19.rs (the code with log10(), do you remember?, September?).
Here is the code after the first step of refactorization:
// ex22.rs
mod my_api {
use serde::Deserialize;
use std::fmt;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
#[derive(Debug, Deserialize)]
pub struct Config {
pub app_name: String,
pub port: u16,
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Parse(serde_json::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "I/O error: {e}"),
ConfigError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
pub fn load_or_init(path: &str) -> Result<Config, ConfigError> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#).map_err(ConfigError::Io)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
}
use my_api::load_or_init;
use std::fs::write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Hopefully the output is exactly the same:
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: Parse(Error("invalid type: string \"not a number\", expected u16", line: 1, column: 44))
error: process didn't exit successfully: `target\debug\examples\ex22.exe` (exit code: 1)
Now, concerning the refactoring we can observe:
- We now have a
mod my_apiat the top of the code - This line declares and brings the content of the namespace
my_apiinto the current crate. - Since the content of the module
my_apiis in the crate root, the modulemy_apiis its child and its symbols can be accessed with themy_api::blah_blah_blahsyntax. - The
use my_api::load_or_init;statement is a “shortcut” that helps to writeload_or_init("bad_config.json")rather than the namespace syntaxmy_api::load_or_init("bad_config.json").
Side Note
If you don’t feel 100% confident with modules, crates, files… You can read this post
ConfigErroris now public because it is part ofload_or_init()which is public
In this first step of the refactoring the main idea was to split the code in 2:
my_apimodule on one end- and a consumer of the API on the other.
Now that we have our library crate set up, let’s explore how to make use of the thiserror crate. So now, we refactor ex22.rs into ex24.rs. Here it is:
// ex24.rs
mod my_api {
use serde::Deserialize;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
use thiserror::Error;
type Result<T> = std::result::Result<T, ConfigError>;
#[derive(Debug, Deserialize)]
pub struct Config {
pub app_name: String,
pub port: u16,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Parse(#[from] serde_json::Error),
}
fn load_config(path: &str) -> Result<Config> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
pub fn load_or_init(path: &str) -> Result<Config> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
}
use my_api::load_or_init;
use std::fs::write;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
- The code of the client (
main()) remains unchanged. - Changes occurs in the API and the biggest one is in
ConfigErrorenumdefinition.
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Parse(#[from] serde_json::Error),
}
- The directive
#[error...and#[from...make the macro generates concrete implementations at compile time, and then the?inload_config()uses those implementations via static conversions. - This is why we no longer need the
impl fmt::Display for ConfigError{...}nor theimpl Error for ConfigError {}. - The signature of
load_config()can be simplified - Idem for the signature of
load_or_init(). In addition themap_err()can be removed.
At the end we have an API and a consumer. In the API, we delegate to thiserror the writing of the implementations. I hope your understand the refactoring process that bring us from ex17.rs to ex24.rs one step after the other. I hope you enjoyed to read complete code at each step.
Summary – anyhow & thiserror
Summary –
anyhow&thiserror
anyhow: Binaries. Dynamic error type with great ergonomics and.context(...)for adding messages. Best for applications where we just want to bubble errors up and print them, not pattern-matchon them.use anyhow::{Context, Result}; use std::fs; fn run() -> Result<String> { let data = fs::read_to_string("Cargo.toml").context("while reading Cargo.toml")?; Ok(data) } fn main() -> Result<()> { let data = run()?; println!("Config loaded: {}", data); Ok(()) }thiserror: Libraries. Derive-based crate to build clear, typed error enums with minimal boilerplate. Best for libraries and public APIs where the caller needs to inspect error kinds.use thiserror::Error; #[derive(Debug, Error)] enum ConfigError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), } fn load(path: &str) -> Result<String, ConfigError> { Ok(std::fs::read_to_string(path)?) // auto-converts into ConfigError::Io } fn main() -> Result<(), ConfigError> { let content = load("Cargo.toml")?; println!("Loaded: {}", content); Ok(()) }- Don’t mix them blindly: We can use both in the same package (e.g., library crates with
thiserrorexposed, binary crate usinganyhowinternally), but try to keep public APIs typed and internal app code ergonomic.
Exercises – anyhow & thiserror
-
Can you explain why in the API of
ex24.rswe foundtype Result<T> = std::result::Result<T, ConfigError>;while in the client’s code we havetype Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; -
Refactor to
thiserror: Take our custom error enum from the previous exercise and replace the manualDisplay/Errorimplementations with a#[derive(Error)]and#[error(...)]attributes fromthiserror. If we had conversions fromio::Errororserde_json::Error, add#[from]to those variants and remove our manualFromimpls. -
Add Context with
anyhow: Write a small binary that reads a file and parses JSON, returninganyhow::Result<()>. Add.context(reading file)and.context(parsing JSON)to the respective fallible operations. Run it with a missing file and with invalid JSON to see the difference in error messages with the added context. -
Design Choice: Given a package that has both a library crate (
my_lib) and a binary crate (my_cli) in a Cargo workspace, decide how we would structure error handling across both. Hint:my_libexposes typed errors withthiserror, whilemy_clidepends onmy_liband usesanyhowinmainto convertmy_lib::Errorintoanyhow::Errorusing?and print user-friendly messages.