You want to log an error but still bubble it up with ?. The usual trick is .map_err with a closure that sneaks in a eprintln! and returns the error unchanged. Result::inspect_err does that for you — and reads like what you meant.
The problem: logging mid-chain
You’re happily propagating errors with ?, but somewhere in the pipeline you want to peek at the failure — log it, bump a metric, attach context — without otherwise touching the value:
1
2
3
4
5
6
7
8
9
| fn load_config(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
fn run(path: &str) -> Result<String, std::io::Error> {
// We want to log the error here, but still return it.
let cfg = load_config(path)?;
Ok(cfg)
}
|
Adding a println! means breaking the chain, introducing a match, or writing a no-op map_err:
1
2
3
4
5
6
7
8
9
10
| # fn load_config(path: &str) -> Result<String, std::io::Error> {
# std::fs::read_to_string(path)
# }
fn run(path: &str) -> Result<String, std::io::Error> {
let cfg = load_config(path).map_err(|e| {
eprintln!("failed to read {}: {}", path, e);
e // have to return the error unchanged — easy to mess up
})?;
Ok(cfg)
}
|
That closure always has to end with e. Miss it and you’re silently changing the error type — or worse, swallowing it.
The fix: inspect_err
Stabilized in Rust 1.76, Result::inspect_err runs a closure on the error by reference and passes the Result through untouched:
1
2
3
4
5
6
7
8
| # fn load_config(path: &str) -> Result<String, std::io::Error> {
# std::fs::read_to_string(path)
# }
fn run(path: &str) -> Result<String, std::io::Error> {
let cfg = load_config(path)
.inspect_err(|e| eprintln!("failed to read config: {e}"))?;
Ok(cfg)
}
|
The closure takes &E, so there’s nothing to return and nothing to get wrong. The value flows straight through to ?.
The Ok side too
There’s a mirror image for the happy path: Result::inspect. Same idea — &T in, nothing out, value preserved:
1
2
3
4
5
6
| let parsed: Result<u16, _> = "8080"
.parse::<u16>()
.inspect(|port| println!("parsed port: {port}"))
.inspect_err(|e| eprintln!("parse failed: {e}"));
assert_eq!(parsed, Ok(8080));
|
Both methods exist on Option too (Option::inspect) — handy when you want to trace a Some without consuming it.
Why it’s nicer than map_err
map_err is for transforming errors. inspect_err is for observing them. Using the right tool means:
- No accidental error swallowing — the closure can’t return the wrong type.
- The intent is obvious at a glance: this is a side effect, not a transformation.
- It composes cleanly with
?, and_then, and the rest of the Result toolbox.
1
2
3
4
5
6
7
| # fn load_config(path: &str) -> Result<String, std::io::Error> {
# std::fs::read_to_string(path)
# }
// Chain several observations together without ceremony.
let _ = load_config("/does/not/exist")
.inspect(|cfg| println!("loaded {} bytes", cfg.len()))
.inspect_err(|e| eprintln!("load failed: {e}"));
|
Reach for inspect_err any time you’d otherwise write map_err(|e| { log(&e); e }) — you’ll have one less footgun and one less line.