Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

My prompt:

I am writing a large rust codebase with several libraries and binaries. The code will only be used by myself so doesn't have to take external users into account at all.

I want to have a proper way of handling errors without having to write too much boiler plate code and matches everywhere.

What are best practices for error handling in my scenario and what do experts advice on this? I would prefer not to bring in external crates. Perhaps jon Gjengset has good advice or others?

  • Should I use ? as much as possible
  • What should my typical return type be? Result<something, Error?>
  • How to handle/catch Errors defined by other external crates and capture them easily upstream without too much hassle
  • Should i define my own error types to make this easier?
  • Is there a way to avoid using Box all over the place?

Think hard about maintainability for my requirements and being ideomatic rust

The 80/20 pattern (no external crates)

I suspect this 80/20 line comes from this blog.

The Rust by Example book has a chapter that looks a lot like this approach.

Per library crate

  1. Define one crate‑local error enum and a Result<T> alias.
  2. Implement Display, std::error::Error, and a few From<_> conversions for external error types you actually use.
  3. Lean on ? everywhere; it auto‑converts via From. 12

Per binary crate

Return Result<(), Box<dyn std::error::Error>> from main (or use your own top‑level error). This is the only place you need a Box, keeping it out of the rest of your code. Rust’s Termination trait makes main returning a Result work as expected (nonzero exit when Err). 3

Minimal template

#![allow(unused)]
fn main() {
// lib/src/error.rs
use std::{error::Error as StdError, fmt};

#[derive(Debug)]
pub enum Error {
    Io(std::io::Error),
    Json(serde_json::Error),
    // Add more *typed* variants as needed.
    // Optionally a catch‑all if you want (kept rare):
    Other(String),
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Io(e) => write!(f, "I/O error: {e}"),
            Error::Json(e) => write!(f, "JSON error: {e}"),
            Error::Other(msg) => write!(f, "{msg}"),
        }
    }
}

impl StdError for Error {
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        match self {
            Error::Io(e) => Some(e),
            Error::Json(e) => Some(e),
            Error::Other(_) => None,
        }
    }
}

// Focused conversions so `?` just works:
impl From<std::io::Error> for Error {
    fn from(e: std::io::Error) -> Self { Error::Io(e) }
}
impl From<serde_json::Error> for Error {
    fn from(e: serde_json::Error) -> Self { Error::Json(e) }
}

// Your crate-wide Result alias
pub type Result<T> = std::result::Result<T, Error>;
}
#![allow(unused)]
fn main() {
// lib/src/lib.rs
mod error;
pub use error::{Error, Result};

use std::fs;

pub fn load_config(path: &str) -> Result<serde_json::Value> {
    let data = fs::read_to_string(path)?;     // auto-converts io::Error -> Error via From
    let v = serde_json::from_str(&data)?;     // auto-converts serde_json::Error -> Error via From
    Ok(v)
}
}
// bin/src/main.rs
use mylib::{load_config, Error};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = load_config("config.json")?; // prints a neat chain on failure
    println!("{cfg}");
    Ok(())
}
  • Why this works well:
    • ? is the standard way to propagate errors; it’s concise and idiomatic. 14
    • Implementing std::error::Error + Display lets errors compose and present well. 2
    • Keeping Box<dyn Error> only at the binary boundary avoids “Box everywhere” while still letting main fail gracefully with a proper exit code via Termination. 3

Adding lightweight context (still no external crates)

If you sometimes want more context (e.g., which file failed to parse), add a single contextual variant and a tiny helper trait:

#![allow(unused)]
fn main() {
// in error.rs
use std::borrow::Cow;

#[derive(Debug)]
pub enum Error {
    Io(std::io::Error),
    Json(serde_json::Error),
    Context { msg: Cow<'static, str>, source: Box<dyn StdError + Send + Sync> },
    Other(String),
}

impl From<std::io::Error> for Error { /* as before */ }
impl From<serde_json::Error> for Error { /* as before */ }

// Extension trait to tack on context ergonomically
pub trait ResultExt<T> {
    fn context(self, msg: impl Into<Cow<'static, str>>) -> Result<T>;
}

impl<T, E> ResultExt<T> for std::result::Result<T, E>
where
    E: StdError + Send + Sync + 'static,
{
    fn context(self, msg: impl Into<Cow<'static, str>>) -> Result<T> {
        self.map_err(|e| Error::Context { msg: msg.into(), source: Box::new(e) })
    }
}
}

Usage:

#![allow(unused)]
fn main() {
use crate::error::ResultExt;

fn load_config(path: &str) -> Result<serde_json::Value> {
    let data = std::fs::read_to_string(path)
        .context(format!("reading config from {path}"))?;
    serde_json::from_str(&data)
        .context(format!("parsing JSON from {path}"))
}
}
  • This keeps your enum typed for the common cases and uses one boxed variant only when you add context, not “all over the place.”
  • If you want stack traces during debugging, you can attach a std::backtrace::Backtrace field to Context and enable it with RUST_BACKTRACE=1 (or RUST_LIB_BACKTRACE=1). 5

Answers to your specific questions

1) “Should I use ? as much as possible?”

Yes. Use ? whenever you cannot or do not want to recover at that level; it’s the idiomatic, concise way to propagate errors, and it triggers From to convert error types along the way. 1
Reserve explicit match only for cases where you’re handling the error (retrying, defaulting, logging with additional side‑effects, etc.). The Book’s error chapter lays out this philosophy (recoverable Result vs unrecoverable panic!). 4

2) “What should my typical return type be?”

  • In library crates: pub type Result<T> = std::result::Result<T, crate::Error>; with a crate‑specific Error enum. This keeps signatures short and type‑checked. 2
  • In binary crates: either reuse your library Error or keep the boundary generic:
    fn main() -> Result<(), Box<dyn std::error::Error>> (simplest), relying on Termination to convert Err to a non‑zero exit code. 3

3) “How do I capture external crate errors upstream without hassle?”

Add From<TheirError> impls to your error enum; then ? “just works.” Also, implement source() to expose the underlying error so higher layers (or your CLI) can print the chain. The standard library’s Error trait is designed for this chaining. 2

#![allow(unused)]
fn main() {
impl From<reqwest::Error> for Error { fn from(e: reqwest::Error) -> Self { Error::Other(e.to_string()) } }
// or better: add a dedicated variant `Http(reqwest::Error)` and a Display arm
}

When adding context, use the small ResultExt::context trick above rather than writing verbose map_err everywhere.

4) “Should I define my own error types to make this easier?”

Yes. A domain‑specific error enum per crate is a widely used pattern. It avoids stringly‑typed errors, makes it easy to distinguish cases, and composes well. The Book/std::error::Error docs show how to implement Display + Error so they print nicely and chain correctly. 24
If you expect your library crates to evolve, you can mark the enum #[non_exhaustive] (optional, more relevant for public APIs).

5) “Is there a way to avoid using Box all over the place?”

Absolutely. Keep errors typed (enums) throughout your libs; use at most one Box<dyn Error>:

  • at the binary boundary (main) for convenience, and/or
  • in a single contextual variant (if you want arbitrary sources + messages).
    Everywhere else, use concrete variants, which are zero‑cost and ergonomic with ?.

A few “expert‑style” tips

  • When to panic!: only for programmer‑bugs / invariant violations (e.g., unreachable!(), “this should never happen”), not for I/O or user‑driven failures. The Book explicitly distinguishes recoverable from unrecoverable errors. 4
  • Display vs Debug: make Display short and human‑readable; rely on source() chains for details. Debug can be derived. 2
  • Backtraces in development: consider adding a Backtrace field (captured on construction) to your contextual variant and enable it via env vars when you need it. 5
  • main ergonomics: returning Result from main is fine and standard; the Termination trait glues it to exit codes. If you need full control over formatting, handle the top error yourself instead of returning it. 3

If you ever relax “no external crates”

Many experienced Rustaceans use:

  • thiserror (derive Error + Display + #[from] on variants) to eliminate boilerplate in library error enums. 6
  • anyhow in binary crates for an ergonomic anyhow::Error + .context() builder (you can still keep typed errors in libs). 7

You don’t need them for your scenario, but they are the shortest path to zero boilerplate if you change your mind.

TL;DR (what to do tomorrow)

  1. Per lib: create Error enum + Result<T> alias; implement a handful of From impls and Display.
  2. Use ? everywhere you propagate; map_err only when you genuinely handle/transform; optional ResultExt::context to add messages. 1
  3. Per bin: fn main() -> Result<(), Box<dyn std::error::Error>> (or your top‑level typed error) and let Termination handle exit codes. 3
  4. Keep Box at the edges, not in your core APIs.

If you want, I can generate the error scaffolding for each of your crates (enums + From impls + ResultExt) based on the external errors you use—just tell me which crates pop up most (e.g., tokio, reqwest, serde_json, rusqlite, etc.). Also, are you OK with a single derive crate (thiserror) if it replaces ~50–100 lines of boilerplate per crate, or do you want to stay 100% std-only? 6