Skip to content

Fix: Rust Error Handling Not Working — ? Operator, Custom Error Types, and thiserror/anyhow

FixDevs · (Updated: )

Part of:  Go, Rust & Systems Errors

Quick Answer

How to fix Rust error handling issues — the ? operator, From trait for error conversion, thiserror for custom errors, anyhow for applications, and Box<dyn Error> pitfalls.

The Problem

The ? operator fails to compile with a type mismatch:

use std::fs;
use std::num::ParseIntError;

fn read_number(path: &str) -> Result<i32, ParseIntError> {
    let content = fs::read_to_string(path)?;  // Error!
    // the `?` operator can only be used in a function that returns `Result`
    // or `Option`, but this function returns `Result<i32, ParseIntError>`
    // cannot convert `std::io::Error` into `ParseIntError`
    content.trim().parse()
}

Or a custom error type doesn’t work with ?:

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

fn process() -> Result<i32, AppError> {
    let content = fs::read_to_string("file.txt")?;  // Error: io::Error ≠ AppError
    let n: i32 = content.trim().parse()?;            // Error: ParseIntError ≠ AppError
    Ok(n)
}

Or Box<dyn std::error::Error> compiles but loses error type information:

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let n: i32 = "not a number".parse()?;  // Works, but how do you handle specific errors?
    Ok(())
}

Why This Happens

Rust’s ? operator desugars to a match that pulls the Ok value out on success and, on failure, calls From::from(err) on the error before returning. For ? to work, the error type being converted must implement From<SourceError> for the function’s return error type. Without that From impl, the compiler refuses to wire the two types together and you get the type-mismatch error you see on the source line.

This trips people up because ? looks like a punctuation feature, not a trait-driven one. The desugaring is roughly: let x = match expr { Ok(v) => v, Err(e) => return Err(From::from(e)) }. If you read it that way, the error message stops being mysterious — From<io::Error> for ParseIntError does not exist, so the compiler cannot satisfy the conversion. The fix is always one of: implement the conversion yourself, derive it (via thiserror’s #[from]), or pick a return type that already has a blanket From (like Box<dyn Error> or anyhow::Error).

A second class of confusion is heterogeneous error sources. A function that calls fs::read_to_string, then serde_json::from_str, then sqlx::query, can fail in three different ways. You need either a custom enum that has From impls for all three, a trait object (Box<dyn Error>) that erases the concrete type at the cost of pattern matching, or anyhow::Error which is essentially a smarter trait object with backtraces and context chains. Library code should prefer the enum; binary crates can lean on anyhow.

The third pitfall is main(). Rust allows fn main() -> Result<(), E> only when E: Debug, because the runtime prints the error via Debug. If you write fn main() -> Result<(), MyError> and forget to derive Debug, the compiler error points at main rather than your enum, which makes it look like ? is broken. The cleanest fix is fn main() -> Result<(), Box<dyn std::error::Error>> or fn main() -> anyhow::Result<()>.

Version History: Rust Error Handling Evolution

Rust’s error story has shifted noticeably across stable releases. Knowing which version you target tells you which patterns are available and which workarounds are obsolete.

  • Rust 1.0 (May 2015) — shipped with panic!, Result<T, E>, and the std::error::Error trait. No ? operator yet; the canonical pattern was the try! macro, which is now deprecated. Every error conversion was manual.
  • Rust 1.13 (November 2016) — introduced the ? operator as a successor to try!. It only worked on Result initially. Most third-party error libraries from this era (error-chain, failure) predate later improvements.
  • Rust 1.22 (November 2017) — extended ? to work on Option, with None short-circuiting the same way Err did. This is when patterns like let first = nums.first()?; became idiomatic.
  • Rust 1.34 (April 2019) — allowed ? on the new TryFrom trait and on functions returning custom types via the unstable Try trait at the time. More importantly, fn main() -> Result<(), E> where E: Debug became the recommended entry-point form.
  • Rust 1.39 (November 2019)async fn and .await reached stable. ? works inside async functions identically to sync ones, which is what makes the anyhow::Result<T> pattern so ergonomic in async code.
  • Rust 1.42 (March 2020)matches! macro arrived, useful for error pattern matching without a full match block.
  • Rust 1.65 (November 2022) — Generic Associated Types (GATs) reached stable. Libraries like thiserror 2.0 and newer can now express slot-based error types more precisely, and async traits with error returns became practical.
  • Rust 1.81 (September 2024)core::error::Error was stabilized, meaning no_std crates can finally implement the Error trait without an std shim. This unlocks thiserror in embedded and wasm contexts.
  • thiserror and anyhow (Oct 2019 onward) — both authored by dtolnay, replacing the older failure crate. thiserror 1.0 was released in October 2019; anyhow 1.0 followed shortly after. Both crates have been API-stable for over five years, which is why most modern Rust code uses them.

If you’re maintaining a crate that still uses error-chain or failure, migrating to thiserror is almost always a net simplification — both legacy crates are unmaintained and have known soundness issues around Send/Sync bounds.

Fix 1: Implement From for Each Error Type

The ? operator calls From::from() to convert errors. Implement From for each source error:

use std::fs;
use std::num::ParseIntError;
use std::io;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

// Implement From<io::Error> for AppError
impl From<io::Error> for AppError {
    fn from(err: io::Error) -> Self {
        AppError::Io(err)
    }
}

// Implement From<ParseIntError> for AppError
impl From<ParseIntError> for AppError {
    fn from(err: ParseIntError) -> Self {
        AppError::Parse(err)
    }
}

// Now ? works for both io::Error and ParseIntError
fn read_number(path: &str) -> Result<i32, AppError> {
    let content = fs::read_to_string(path)?;  // io::Error → AppError::Io
    let n: i32 = content.trim().parse()?;      // ParseIntError → AppError::Parse
    Ok(n)
}

fn main() {
    match read_number("numbers.txt") {
        Ok(n) => println!("Number: {}", n),
        Err(AppError::Io(e)) => eprintln!("IO error: {}", e),
        Err(AppError::Parse(e)) => eprintln!("Parse error: {}", e),
    }
}

Also implement std::fmt::Display for the error type:

use std::fmt;

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

impl std::error::Error for AppError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AppError::Io(e) => Some(e),
            AppError::Parse(e) => Some(e),
        }
    }
}

Fix 2: Use thiserror for Library Code

The thiserror crate generates Display, Error, and From implementations automatically:

# Cargo.toml
[dependencies]
thiserror = "1"
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO error: {0}")]          // Display implementation
    Io(#[from] std::io::Error),        // From<io::Error> generated automatically

    #[error("Parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("User not found: id={id}")]
    UserNotFound { id: u64 },

    #[error("Invalid config: {message}")]
    Config { message: String },

    #[error("Database error")]
    Database(#[source] sqlx::Error),   // #[source] without #[from]: won't auto-convert
}

// Now ? works directly — thiserror generated From impls
fn read_number(path: &str) -> Result<i32, AppError> {
    let content = std::fs::read_to_string(path)?;  // io::Error → AppError::Io
    let n: i32 = content.trim().parse()?;           // ParseIntError → AppError::Parse
    Ok(n)
}

#[from] vs #[source]:

#[derive(Debug, Error)]
enum AppError {
    // #[from] — generates From<sqlx::Error> AND sets the source
    #[error("DB error: {0}")]
    Database(#[from] sqlx::Error),

    // #[source] — only sets the error source (for error chains)
    // Does NOT generate From — you must convert manually
    #[error("Cache error")]
    Cache(#[source] redis::RedisError),
}

Fix 3: Use anyhow for Application Code

anyhow is ideal for application-level code where you don’t need to match on specific error types:

[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail, ensure};

// anyhow::Result<T> is Result<T, anyhow::Error>
fn process_file(path: &str) -> Result<i32> {
    // Any error type that implements std::error::Error works with ?
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read file: {}", path))?;

    let n: i32 = content.trim().parse()
        .context("Failed to parse file content as integer")?;

    Ok(n)
}

// bail! — return an error immediately
fn validate_age(age: i32) -> Result<()> {
    if age < 0 {
        bail!("Age cannot be negative: {}", age);
    }
    if age > 150 {
        bail!("Age seems unrealistic: {}", age);
    }
    Ok(())
}

// ensure! — like assert! but returns an error instead of panicking
fn check_permissions(user_id: u64, required_role: &str) -> Result<()> {
    let user = get_user(user_id)?;
    ensure!(
        user.roles.contains(&required_role.to_string()),
        "User {} lacks required role: {}",
        user_id,
        required_role
    );
    Ok(())
}

fn main() -> Result<()> {
    let n = process_file("input.txt")?;
    println!("Number: {}", n);
    Ok(())
}

When to use thiserror vs anyhow:

thiserroranyhow
Best forLibrariesApplications
Error matchingYes — enum variantsNo — opaque type
Error messagesDefined at type levelAttached at use site
OverheadZeroSmall allocation
Caller handles specific errorsYesNo

Fix 4: Fix ? in Functions Returning Option

? also works in functions returning Option:

fn find_first_even(nums: &[i32]) -> Option<i32> {
    // ? on Option returns None if the value is None
    let first = nums.first()?;  // Returns None if slice is empty
    nums.iter().find(|&&n| n % 2 == 0).copied()
}

// Converting between Result and Option
fn parse_optional(s: Option<&str>) -> Result<i32, std::num::ParseIntError> {
    // ok_or / ok_or_else converts Option to Result
    let s = s.ok_or_else(|| "missing value".parse::<i32>().unwrap_err())?;
    s.parse()
}

// Using ? with both Option and Result — requires matching return types
fn mixed() -> Option<i32> {
    // ? on Result inside Option-returning function — doesn't compile directly
    // Use .ok()? to convert Result<T, E> → Option<T>
    let content = std::fs::read_to_string("file.txt").ok()?;
    content.trim().parse::<i32>().ok()
}

Fix 5: Error Handling Patterns in Async Code

? works in async functions the same as sync:

use tokio;
use anyhow::Result;

async fn fetch_user(id: u64) -> Result<User> {
    let response = reqwest::get(format!("https://api.example.com/users/{}", id))
        .await
        .context("Failed to send request")?;

    if !response.status().is_success() {
        anyhow::bail!("API returned status {}", response.status());
    }

    let user: User = response.json()
        .await
        .context("Failed to parse response")?;

    Ok(user)
}

// Handling multiple concurrent errors
async fn fetch_all(ids: Vec<u64>) -> Result<Vec<User>> {
    let futures: Vec<_> = ids.iter().map(|&id| fetch_user(id)).collect();
    let results = futures::future::join_all(futures).await;

    // Collect results, fail on first error
    results.into_iter().collect::<Result<Vec<_>>>()
}

Fix 6: Map and Recover from Errors

Transform and recover from errors without early return:

use std::fs;

fn read_config(path: &str) -> String {
    // Provide a default on error
    fs::read_to_string(path).unwrap_or_else(|_| String::from("{}"))
}

fn parse_port(s: &str) -> u16 {
    s.parse()
     .map_err(|e| eprintln!("Invalid port '{}': {}", s, e))
     .unwrap_or(8080)  // Default port on parse error
}

fn chain_operations(path: &str) -> Result<i32, AppError> {
    fs::read_to_string(path)
        .map_err(AppError::Io)?       // Convert io::Error to AppError::Io
        .trim()
        .parse::<i32>()
        .map_err(AppError::Parse)     // Convert ParseIntError to AppError::Parse
}

// map vs and_then
fn double_file_number(path: &str) -> Result<i32, AppError> {
    read_number(path)
        .map(|n| n * 2)               // Transform Ok value
        .and_then(|n| {               // Chain another fallible operation
            if n > 1000 {
                Err(AppError::Config { message: "Number too large".to_string() })
            } else {
                Ok(n)
            }
        })
}

Still Not Working?

? requires a return type of Result or Option? doesn’t work in a function that returns () or any other type. If you’re in main(), change the return type to Result<(), Box<dyn std::error::Error>> or anyhow::Result<()>.

Lifetime issues with Box<dyn Error>Box<dyn std::error::Error> requires the error to be 'static by default. If your error contains references, use Box<dyn std::error::Error + 'lifetime>. This is rarely needed — prefer owned error types.

anyhow::Error in library crates — using anyhow::Error as a public return type forces library users to depend on anyhow. Use thiserror for public APIs, anyhow internally if needed.

Multiple ? operators with conflicting types — if a function uses ? on both io::Error and reqwest::Error, you need either a common error type with From impls for both, or use anyhow::Result which accepts any std::error::Error.

? inside a closure swallows errors silently — closures have their own return type, so ? inside a closure returns from the closure, not the enclosing function. If the closure returns Result<T, E> and you call .map(|x| something()?) without handling the inner result, the compiler usually catches it, but .for_each(|x| { let _ = something(); }) patterns swallow errors. Use try_fold or collect::<Result<Vec<_>, _>>() to propagate errors out of iterator chains.

Send / Sync bounds break async error propagation — if your error type contains a non-Send field (a raw pointer, an Rc, a non-Send future), it cannot cross an .await point. tokio::spawn requires Future: Send + 'static, and the compiler will reject the future at the spawn site with an opaque error about the inner type. Replace Rc with Arc, and make sure third-party errors you ? into your type are Send + Sync.

#[error("...")] interpolation drops fields silently — in thiserror, the message string only sees fields you reference by name ({field}) or position ({0}). Adding a new field to the enum variant does not automatically include it in the Display output. After every variant edit, re-check the message string contains every field you want logged.

For related Rust issues, see Fix: Rust Borrow Checker Error, Fix: Rust Lifetime Error, Fix: Rust Cannot Borrow As Mutable, and Fix: Rust Trait Not Implemented.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles