Fix: Rust Error Handling Not Working — ? Operator, Custom Error Types, and thiserror/anyhow
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 thestd::error::Errortrait. No?operator yet; the canonical pattern was thetry!macro, which is now deprecated. Every error conversion was manual. - Rust 1.13 (November 2016) — introduced the
?operator as a successor totry!. It only worked onResultinitially. Most third-party error libraries from this era (error-chain,failure) predate later improvements. - Rust 1.22 (November 2017) — extended
?to work onOption, withNoneshort-circuiting the same wayErrdid. This is when patterns likelet first = nums.first()?;became idiomatic. - Rust 1.34 (April 2019) — allowed
?on the newTryFromtrait and on functions returning custom types via the unstableTrytrait at the time. More importantly,fn main() -> Result<(), E> where E: Debugbecame the recommended entry-point form. - Rust 1.39 (November 2019) —
async fnand.awaitreached stable.?works inside async functions identically to sync ones, which is what makes theanyhow::Result<T>pattern so ergonomic in async code. - Rust 1.42 (March 2020) —
matches!macro arrived, useful for error pattern matching without a fullmatchblock. - Rust 1.65 (November 2022) — Generic Associated Types (GATs) reached stable. Libraries like
thiserror2.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::Errorwas stabilized, meaningno_stdcrates can finally implement theErrortrait without anstdshim. This unlocksthiserrorin embedded andwasmcontexts. thiserrorandanyhow(Oct 2019 onward) — both authored by dtolnay, replacing the olderfailurecrate.thiserror1.0 was released in October 2019;anyhow1.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:
thiserror | anyhow | |
|---|---|---|
| Best for | Libraries | Applications |
| Error matching | Yes — enum variants | No — opaque type |
| Error messages | Defined at type level | Attached at use site |
| Overhead | Zero | Small allocation |
| Caller handles specific errors | Yes | No |
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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.
Fix: Java Record Not Working — Compact Constructor Error, Serialization Fails, or Cannot Extend
How to fix Java record issues — compact constructor validation, custom accessor methods, Jackson serialization, inheritance restrictions, and when to use records vs regular classes.
Fix: Kotlin Sealed Class Not Working — when Expression Not Exhaustive or Subclass Not Found
How to fix Kotlin sealed class issues — when exhaustiveness, sealed interface vs class, subclass visibility, Result pattern, and sealed classes across modules.
Fix: Python contextmanager Not Working — GeneratorExit, Missing yield, or Cleanup Not Running
How to fix Python context manager issues — @contextmanager generator, __enter__ and __exit__, exception handling inside with blocks, async context managers, and common pitfalls.