Error Handling in Rust: Result, the ? Operator, and thiserror
Photo: Unsplash
Coming from languages with exceptions, Rust's error handling felt verbose at first. There is no try/catch that lets an error silently unwind half your call stack. Instead, a function that can fail returns its failure in the type signature, and you have to acknowledge it. That acknowledgement felt like ceremony until I realized it was the whole point: in Rust, you cannot accidentally ignore an error, because the compiler will not let you.
What I did not appreciate early on is how little boilerplate this actually requires once you know the tools. The ? operator collapses most of the verbosity, and the thiserror crate removes nearly all the rest for custom error types. This post traces the path from manual matching to clean, idiomatic error handling.
Result is just an enum
The foundation is Result<T, E>, an ordinary enum with two variants: Ok(T) for success and Err(E) for failure. There is no magic. A function that parses a number returns Result<i32, ParseIntError>, and the caller decides what to do with each case.
use std::num::ParseIntError;
fn double(input: &str) -> Result<i32, ParseIntError> {
match input.parse::<i32>() {
Ok(n) => Ok(n * 2),
Err(e) => Err(e),
}
}This works, but look at that match. All it does is unwrap the success and pass the error straight through. When every fallible call needs this pattern, your functions drown in matching. The Rust Book's error handling chapter introduces this verbose form first precisely so you appreciate what comes next.
The ? operator does the boring part
The ? operator replaces that entire match. Placed after a Result, it unwraps the Ok value or returns the Err from the current function immediately. The function above collapses to one line of logic.
use std::num::ParseIntError;
fn double(input: &str) -> Result<i32, ParseIntError> {
let n = input.parse::<i32>()?; // on Err, return it from double()
Ok(n * 2)
}The ? reads as "give me the value or bail out." It only works inside a function that itself returns Result (or Option), because it needs somewhere to return the error to. This is the operator you will use constantly. It keeps the happy path readable while still forcing every error to be handled or deliberately propagated.
use std::fs;
fn read_config(path: &str) -> Result<String, std::io::Error> {
let raw = fs::read_to_string(path)?; // propagate any IO error
Ok(raw.trim().to_string())
}? is not exception handling in disguise. The error still travels through your function's return type, fully visible in the signature. The difference from exceptions is that nothing is hidden: you can read a function's type and know exactly how it can fail.
The friction point: many error types
The ? operator has one requirement that bites once your program grows. The error type you propagate must convert into the error type your function returns. A function that reads a file and parses a number can hit two different error types, std::io::Error and ParseIntError, and you cannot return both from one signature without a unifying type.
The classic solution is a custom error enum with a variant per failure mode. Hand-written, that means defining the enum, implementing Display so it prints nicely, implementing the Error trait, and writing a From conversion for each underlying error so ? can convert automatically. It is mechanical, repetitive code that every project reinvents.
use std::fmt;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "io error: {e}"),
ConfigError::Parse(e) => write!(f, "parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<std::num::ParseIntError> for ConfigError {
fn from(e: std::num::ParseIntError) -> Self { ConfigError::Parse(e) }
}That is a lot of code to express a simple idea. Every one of those From impls exists only so ? can do its job.
thiserror removes the boilerplate
The thiserror crate generates all of that from annotations. You describe the error enum and let a derive macro write the Display, Error, and From implementations. The same error type collapses to a fraction of the size.
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
fn load(path: &str) -> Result<i32, ConfigError> {
let raw = std::fs::read_to_string(path)?; // From<io::Error> generated
let value = raw.trim().parse::<i32>()?; // From<ParseIntError> generated
Ok(value)
}The #[error(...)] attribute defines the Display message, and #[from] generates the conversion that makes ? work. You get the same fully typed, exhaustively matchable error enum as the hand-written version with almost none of the typing. Its documentation on docs.rs covers the attribute options, including how to wrap a source error while adding context.
A useful rule of thumb from the ecosystem: use thiserror when you are writing a library, because callers benefit from a precise, matchable error type. For application binaries where you mostly want to log an error and exit, a more dynamic approach like a boxed dyn Error or the anyhow crate is often less ceremony. Both styles build on the same Result and ? foundation; they differ only in how much structure the error type carries.
Takeaways
Result<T, E>is a plain enum. Errors live in the type signature, so they cannot be silently ignored.- The
?operator replaces manualmatchplumbing, unwrapping success or propagating the error to the caller. ?requires the propagated error to convert into the function's return error type, which pushes you toward a unifying error enum as code grows.- Hand-writing custom error types means repetitive
Display,Error, andFromimpls. thiserrorgenerates that boilerplate from#[error]and#[from]attributes; reach for it in libraries, and consideranyhowfor application binaries.

