The error that forgot its name
You are building a CLI tool that reads a configuration file. You run the binary, and it crashes. The terminal spits out Os { code: 2, kind: NotFound, message: "No such file or directory" }. You stare at the output. You know the file is missing. The operating system knows the file is missing. But neither of you knows which file is missing. Is it config.toml? database.yml? secrets.env? The error swallowed the context. You need to attach the filename to the error so the next person knows exactly what went wrong.
Errors are values, not exceptions
Rust errors are values. When a function returns a Result<T, E>, the E is the error type. The ? operator is a shorthand for propagation. It checks the Result. If it is Ok, it unwraps the value. If it is Err, it returns the error immediately. The ? operator passes the error through unchanged. It does not add notes. To add context, you must intercept the error, wrap it in a new value that holds the original error plus your message, and return that wrapper. This pattern is called error context or error wrapping.
Think of an error like a note passed in a classroom. If you pass the note directly, the teacher sees the note. If you wrap the note in an envelope and write "From: Math Class, Problem 4" on the outside, the teacher knows the origin. The note is still inside. The context is on the envelope. Rust errors work the same way. You wrap the inner error in a type that carries the extra information.
An error without context is a clue without a case file.
The quick fix: map_err
The simplest way to add context is map_err. This method runs a closure only if the Result is an Err. You take the original error, combine it with your context, and return a new error string.
use std::fs;
/// Reads a file and returns its contents, or a descriptive error string.
fn read_config(path: &str) -> Result<String, String> {
fs::read_to_string(path)
// map_err intercepts the error and transforms it.
// The closure receives the io::Error and returns a new String.
.map_err(|e| format!("Failed to read {}: {}", path, e))
}
fn main() {
match read_config("missing.toml") {
Ok(contents) => println!("Config: {}", contents),
Err(e) => eprintln!("Error: {}", e),
}
}
The output looks like this:
Error: Failed to read missing.toml: No such file or directory (os error 2)
The context is clear. The filename is attached. The user knows exactly what failed.
String errors work for scripts. They break libraries.
Why strings fail in libraries
Returning String as an error type works for quick scripts. It fails in libraries. When you return a String, you lose the type information. The caller cannot check e.kind() to see if the error is NotFound or PermissionDenied. The caller cannot recover. The caller can only print the string. Libraries need typed errors so callers can make decisions.
Convention aside: The Rust community treats String errors as a code smell in public APIs. Use them in main functions or tests. Use typed errors everywhere else.
Realistic context: thiserror and error chains
Real applications need typed errors that preserve the source. You define an enum. Each variant holds the context and the source error. The thiserror crate is the community standard for this. It generates the Display and Error impls for you.
Add thiserror to your project:
cargo add thiserror
Define the error enum:
use std::fs;
use std::io;
// thiserror generates Display and Error implementations.
// It reduces boilerplate and prevents mistakes.
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
// The error message format string.
// {path} and {source} are filled from the struct fields.
#[error("Cannot load config from '{path}': {source}")]
ConfigLoad {
path: String,
// #[source] marks the underlying error.
// This enables error chaining via std::error::Error::source().
#[source]
source: io::Error,
},
}
/// Loads the config file with full context and typed errors.
fn load_config(path: &str) -> Result<String, AppError> {
fs::read_to_string(path)
// map_err constructs the AppError variant.
// The io::Error becomes the source field.
.map_err(|source| AppError::ConfigLoad { path: path.to_string(), source })
}
The #[source] attribute is crucial. It tells the error chain where the root cause lives. Callers can inspect the chain. They can print the full trace. They can check the source type.
fn main() {
match load_config("missing.toml") {
Ok(contents) => println!("Config: {}", contents),
Err(e) => {
eprintln!("Error: {}", e);
// Access the source error if needed.
if let Some(source) = std::error::Error::source(&e) {
eprintln!("Caused by: {}", source);
}
}
}
}
The output shows the context and the cause:
Error: Cannot load config from 'missing.toml': No such file or directory (os error 2)
Caused by: No such file or directory (os error 2)
The #[source] attribute is the glue that holds the error chain together. Without it, the root cause vanishes.
Pitfalls and compiler traps
The From trait trap
If you try to use ? with a custom error type, the compiler demands that the source error implements Into<YourError>. If you have not implemented that, you get E0277 (trait bound not satisfied).
fn bad_example() -> Result<String, AppError> {
// This fails to compile.
// AppError does not implement From<io::Error>.
fs::read_to_string("config.toml")?
}
The compiler error points to the ? operator. It says AppError cannot be built from io::Error. You have two choices. Implement From<io::Error> for AppError, or use map_err. thiserror can generate From impls automatically with the #[from] attribute.
#[derive(Debug, Error)]
enum AppError {
#[error("IO error: {0}")]
#[from]
Io(io::Error),
}
Now ? works. The compiler inserts the conversion automatically.
Context bloat
Wrapping errors too many times creates noise. If every function adds a layer of context, the error message becomes a tower of repetition.
Error: Failed to process request
Caused by: Failed to read config
Caused by: Failed to open file
Caused by: No such file or directory
The first three lines repeat the same information. The user only needs the filename and the root cause. Add context only when it adds new information. If the error already mentions the filename, do not wrap it again with the filename.
If your error message repeats the error type, you are adding noise, not context.
Mismatched types
If you mix error types without conversion, you get E0308 (mismatched types). This happens when a function returns Result<T, AppError> but you try to return a raw io::Error from a branch.
fn mixed_errors() -> Result<String, AppError> {
if some_condition {
// This returns Err(io::Error).
// The function expects Err(AppError).
fs::read_to_string("test.txt")
} else {
Ok("ok".to_string())
}
}
The compiler rejects this. You must map the error or use ? with a From impl. Consistency matters. Pick one error type per function boundary.
Pick one error type per function boundary.
Decision: when to use what
Use map_err with a format string when you are writing a quick script or a binary and don't care about error types. Use a custom error enum with thiserror when you are building a library and need to expose a stable, typed error API. Use anyhow::Result when you are writing application code and want the least friction for adding context via the .context() method. Implement From for your error type when you want to use the ? operator seamlessly across different error sources.
Convention aside: The community splits error handling by crate type. Libraries use thiserror. Applications use anyhow. anyhow provides a Context trait that adds .context("msg") to any Result. It handles the wrapping automatically. It is the standard for app code. Libraries avoid anyhow because it hides the error types from callers.
Trust the error chain. If you can't trace the root cause, your context is hiding the problem, not solving it.