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:

  • anyhow in binaries when we don’t need a public, fine-grained error type and just want easy error propagation with context.
  • thiserror in libraries when we need ergonomic custom error types without writing all impl for Display, 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 Result is a type alias for Result<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.rs is shorter but this is not the point.
  • ConfigError and its implementations has disappear because it is no longer needed.
  • Pay attention to .with_context() in load_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).
  • Also note how the .context(...) in main() 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.rs we had fn load_config(path: &str) -> Result<Config, ConfigError> {...}
  • Now we have fn load_or_init(path: &str) -> Result<Config> {...} where Result is a type alias so that the signature should be read as fn load_config(path: &str) -> std::result::Result<Config, anyhow::Error>
  • anyhow implement From<E> for all E that implement std::error::Error + Send + Sync + 'static
  • If any error happen during read_to_string() then the ? operator converts the error from std::io::Error to anyhow::Error (idem for serde_json::Error from serde_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::Error and check if it is an io::Error. If it is the case we check if it is an ErrorKind::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 receives Result<Config, , anyhow::Error> and not a Result<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_api at the top of the code
  • This line declares and brings the content of the namespace my_api into the current crate.
  • Since the content of the module my_api is in the crate root, the module my_api is its child and its symbols can be accessed with the my_api::blah_blah_blah syntax.
  • The use my_api::load_or_init; statement is a “shortcut” that helps to write load_or_init("bad_config.json") rather than the namespace syntax my_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

  • ConfigError is now public because it is part of load_or_init() which is public

In this first step of the refactoring the main idea was to split the code in 2:

  • my_api module 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 ConfigError enum definition.
    #[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 ? in load_config() uses those implementations via static conversions.
  • This is why we no longer need the impl fmt::Display for ConfigError{...} nor the impl Error for ConfigError {}.
  • The signature of load_config() can be simplified
  • Idem for the signature of load_or_init(). In addition the map_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-match on 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 thiserror exposed, binary crate using anyhow internally), but try to keep public APIs typed and internal app code ergonomic.

Exercises – anyhow & thiserror

  1. Can you explain why in the API of ex24.rs we found type Result<T> = std::result::Result<T, ConfigError>; while in the client’s code we have type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

  2. Refactor to thiserror: Take our custom error enum from the previous exercise and replace the manual Display/Error implementations with a #[derive(Error)] and #[error(...)] attributes from thiserror. If we had conversions from io::Error or serde_json::Error, add #[from] to those variants and remove our manual From impls.

  3. Add Context with anyhow: Write a small binary that reads a file and parses JSON, returning anyhow::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.

  4. 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_lib exposes typed errors with thiserror, while my_cli depends on my_lib and uses anyhow in main to convert my_lib::Error into anyhow::Error using ? and print user-friendly messages.

Posts


Back to top

Published on: Sep 20 2025 at 06:00 PM | Last updated: Sep 26 2025 at 10:00 AM

Copyright © 1964-2026 - 40tude