The Rust Expression Guide
Reshaping Errors with Result Combinators
Last Updated: 2026-04-04
What this page is about
A lot of real Rust code does not merely propagate errors upward unchanged. It reshapes them.
Lower layers may expose errors that are too specific, too technical, or too awkward for the current layer's API. A parsing error may need to become a configuration error. An I/O failure may need a path attached. A domain validation step may need a more user-facing message.
This page focuses on the Result combinators that let you change or enrich the error path without interrupting the flow of successful code. The main tools are map_err, or_else, and closely related patterns.
The central idea is that good Rust error handling is not just about propagation. It is also about shaping errors at the right boundaries so the caller sees the right level of meaning.
The core mental model
When working with Result<T, E>, the success path and error path are separate channels.
maptransforms the success valuemap_errtransforms the error valueand_thensequences another fallible success stepor_elsesequences another computation when an error occurs
That distinction is important because it helps keep the code honest about what is being changed.
fn example(input: &str) -> Result<u32, String> {
input
.parse::<u32>()
.map_err(|e| e.to_string())
}In this example, the successful parsed value is left alone. Only the error representation changes.
Why reshaping errors matters
If code only propagates raw lower-level errors, the public error story of a function often becomes messy.
For example, consider a layer that reads a file path from configuration and then parses a number from the file contents. The low-level errors may be things like std::io::Error and std::num::ParseIntError, but the caller may care about something simpler such as "could not load worker count".
Error reshaping matters because different layers usually need different levels of meaning:
- a low layer may expose exact technical failure types
- a boundary layer may convert them into more stable, higher-level errors
- a user-facing layer may add context that points to the failed resource or field
The goal is not to erase information carelessly. The goal is to present the right information at the right boundary.
Using `map_err`
map_err transforms the Err value of a Result while leaving Ok untouched.
fn parse_age(input: &str) -> Result<u8, String> {
input.parse::<u8>().map_err(|e| e.to_string())
}This is the simplest kind of reshaping: change one error type into another.
A more meaningful example adds context:
fn parse_age(input: &str) -> Result<u8, String> {
input
.trim()
.parse::<u8>()
.map_err(|e| format!("invalid age '{input}': {e}"))
}This is one of the most common real uses of map_err: attach local context at the point where a lower-level failure crosses into a more meaningful layer.
Normalizing lower-level errors
A function often depends on operations that fail in different ways but should appear as one kind of failure to its caller.
use std::fs;
fn load_count(path: &str) -> Result<u32, String> {
fs::read_to_string(path)
.map_err(|e| format!("failed to read {path}: {e}"))
.and_then(|text| {
text.trim()
.parse::<u32>()
.map_err(|e| format!("failed to parse count from {path}: {e}"))
})
}This function normalizes very different lower-level errors into one outward error type: String.
That is not always the right long-term design, but it demonstrates the role of map_err clearly: keep the success flow moving while shaping errors at boundaries.
Adding context at boundaries
One of the most useful things map_err can do is attach boundary-specific context.
Suppose a helper parses a port number:
fn parse_port(raw: &str) -> Result<u16, std::num::ParseIntError> {
raw.trim().parse::<u16>()
}A higher layer may want to make that error more specific:
fn config_port(raw: &str) -> Result<u16, String> {
parse_port(raw).map_err(|e| format!("invalid PORT setting: {e}"))
}The helper remains reusable and precise. The outer layer adds the context that matters in its own domain. This is a very common and healthy pattern.
Using `or_else`
or_else is the error-path counterpart to and_then. It runs only when the Result is Err, and the closure returns another Result.
This makes it useful when error handling itself may branch or recover.
fn parse_with_default(input: &str) -> Result<u32, String> {
input
.trim()
.parse::<u32>()
.map_err(|e| e.to_string())
.or_else(|_| Ok(0))
}That example recovers from any parse error by returning 0.
A more realistic use is selective recovery or fallback:
fn parse_port_or_default(input: Option<&str>) -> Result<u16, String> {
input
.ok_or_else(|| "PORT not set".to_string())
.and_then(|raw| raw.trim().parse::<u16>().map_err(|e| e.to_string()))
.or_else(|err| {
if err == "PORT not set" {
Ok(8080)
} else {
Err(err)
}
})
}The idea is not merely to replace match, but to keep the flow linear when the recovery rule is small and local.
`map_err` vs `or_else`
A good rule is this:
- use
map_errwhen you want to transform one error value into another error value - use
or_elsewhen handling an error may itself succeed or fail
That distinction matters because or_else can recover, defer to another fallible source, or preserve the error after inspection.
fn with_map_err(input: &str) -> Result<u32, String> {
input.parse::<u32>().map_err(|e| format!("parse failed: {e}"))
}
fn with_or_else(input: &str) -> Result<u32, String> {
input
.parse::<u32>()
.map_err(|e| e.to_string())
.or_else(|_| Ok(10))
}The first only reshapes failure. The second turns failure into success.
Keeping success flow intact
One reason combinators are so useful is that they let the success path remain visually stable while the error path is adjusted locally.
use std::fs;
fn read_username(path: &str) -> Result<String, String> {
fs::read_to_string(path)
.map_err(|e| format!("unable to read username file {path}: {e}"))
.map(|text| text.trim().to_string())
}This function is easy to scan because the two transformations are separated cleanly:
map_errhandles failure shapemaphandles success shape
The result is often easier to read than a match that interleaves both concerns.
Meaningful examples
Example 1: normalize a parse error into a domain error.
fn parse_retries(input: &str) -> Result<u8, String> {
input
.trim()
.parse::<u8>()
.map_err(|e| format!("invalid retries value: {e}"))
}Example 2: attach path context to an I/O error.
use std::fs;
fn read_config(path: &str) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("could not read config at {path}: {e}"))
}Example 3: fallback to a default file when the first source fails.
use std::fs;
fn load_text(primary: &str, fallback: &str) -> Result<String, String> {
fs::read_to_string(primary)
.or_else(|_| fs::read_to_string(fallback))
.map_err(|e| format!("failed to load either {primary} or {fallback}: {e}"))
}Example 4: inspect an error and selectively recover.
fn parse_small_number(input: &str) -> Result<u8, String> {
input
.trim()
.parse::<u8>()
.map_err(|e| e.to_string())
.and_then(|n| {
if n <= 10 {
Ok(n)
} else {
Err("number must be at most 10".to_string())
}
})
.or_else(|err| {
if err.contains("invalid digit") {
Ok(0)
} else {
Err(err)
}
})
}A configuration-loading example
Configuration code is a natural place to reshape errors because low-level failures often need application-specific context.
use std::env;
fn worker_count() -> Result<usize, String> {
env::var("WORKER_COUNT")
.map_err(|e| format!("WORKER_COUNT is unavailable: {e}"))
.map(|s| s.trim().to_string())
.and_then(|s| {
s.parse::<usize>()
.map_err(|e| format!("WORKER_COUNT must be a positive integer: {e}"))
})
.and_then(|n| {
if n == 0 {
Err("WORKER_COUNT must be non-zero".to_string())
} else {
Ok(n)
}
})
}This is a good example because each stage shapes errors locally where the relevant context exists.
- environment loading gives one kind of failure
- parsing gives another
- domain validation gives another
The function turns them into one coherent outward error story.
A request-validation example
Request validation often uses combinators to keep successful extraction and failure shaping close together.
struct CreateUserRequest {
email: String,
age: String,
}
fn validated_age(req: &CreateUserRequest) -> Result<u8, String> {
req.age
.trim()
.parse::<u8>()
.map_err(|e| format!("age is invalid: {e}"))
.and_then(|age| {
if age >= 13 {
Ok(age)
} else {
Err("age must be at least 13".to_string())
}
})
}The error reshaping stays local to the age boundary. The surrounding code does not need to know about parse internals.
When combinators are clearer than `match`
Combinators are often clearer when the code is doing one of these things:
- changing only the error shape
- adding a small amount of local context
- applying a small fallback rule
- preserving a straight-line flow through a few fallible steps
For example:
fn parse_threads(input: &str) -> Result<usize, String> {
input
.trim()
.parse::<usize>()
.map_err(|e| format!("THREADS must be a number: {e}"))
}Using match here would add noise without adding clarity.
Similarly:
use std::fs;
fn read_file(path: &str) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("failed to read {path}: {e}"))
}This is exactly the sort of small, local error shaping that combinators do well.
When explicit branching is better
Combinators are not automatically clearer. Explicit branching is usually better when the error logic becomes substantial.
For example, this may be too dense:
fn classify_port(input: &str) -> Result<u16, String> {
input
.trim()
.parse::<u16>()
.map_err(|e| format!("parse failed: {e}"))
.and_then(|port| {
if port == 0 {
Err("port cannot be zero".to_string())
} else if port < 1024 {
Err("privileged ports are not allowed".to_string())
} else {
Ok(port)
}
})
}This is still acceptable, but once the validation or recovery logic grows, a match or plain if structure may be easier to maintain.
A useful heuristic is this: combinators are great for local shaping; if the closure becomes the main story, clearer explicit structure may be better.
Using `?` together with error reshaping
Error combinators often pair naturally with ?.
use std::fs;
fn load_port(path: &str) -> Result<u16, String> {
let text = fs::read_to_string(path)
.map_err(|e| format!("failed to read port file {path}: {e}"))?;
let port = text
.trim()
.parse::<u16>()
.map_err(|e| format!("invalid port in {path}: {e}"))?;
if port == 0 {
Err("port must be non-zero".to_string())
} else {
Ok(port)
}
}This pattern is extremely common in real code. Each fallible boundary gets a small local map_err, and ? keeps the main path linear.
That is often easier to read than one very long chain and often cleaner than one large match.
A small CLI example
Here is a small command-line example that reshapes parse and validation errors cleanly.
use std::env;
fn main() -> Result<(), String> {
let level = env::args()
.nth(1)
.ok_or_else(|| "usage: provide a log level from 0 to 5".to_string())?
.trim()
.parse::<u8>()
.map_err(|e| format!("log level must be a number: {e}"))
.and_then(|n| {
if n <= 5 {
Ok(n)
} else {
Err("log level must be between 0 and 5".to_string())
}
})?;
println!("log level: {level}");
Ok(())
}You can try it with:
cargo run -- 3
cargo run -- 9
cargo run -- nope
cargo runThis example is small, but it shows the full pattern:
- convert absence into an error
- reshape parse failure
- add domain validation
- keep the success path straight
A small project file for experimentation
You can experiment with the examples in a small project like this:
[package]
name = "reshaping-errors-with-result-combinators"
version = "0.1.0"
edition = "2024"Then place one example at a time in src/main.rs and run:
cargo run
cargo fmt
cargo clippy
cargo testcargo fmt helps reveal the structure of error-shaping chains. cargo clippy is useful for spotting manual match blocks that are mostly just wrapper handling.
Common mistakes
There are a few recurring mistakes when reshaping errors.
First, erasing too much information. Converting every error into a vague string too early can make debugging harder.
Second, attaching context repeatedly at multiple layers so the final message becomes noisy or redundant.
Third, using combinators even when the closure contains most of the business logic.
Fourth, recovering with or_else when the code should really fail visibly.
Fifth, mixing success-shaping and error-shaping so tightly that the reader must mentally untangle both at once.
The goal is not to use combinators everywhere. The goal is to make success and failure shaping feel intentional and local.
Refactoring patterns to watch for
When reviewing code, these are strong signals that Result combinators may help:
- a
matchexists only to turn one error type into another - a lower-level error needs local context such as a path, field name, or setting name
- several low-level errors should appear as one outward error type
- a small fallback or recovery rule is interrupting otherwise linear code
Typical before-and-after examples look like this:
fn before(input: &str) -> Result<u32, String> {
match input.parse::<u32>() {
Ok(n) => Ok(n),
Err(e) => Err(format!("invalid number: {e}")),
}
}
fn after(input: &str) -> Result<u32, String> {
input.parse::<u32>().map_err(|e| format!("invalid number: {e}"))
}And for recovery:
fn before(input: &str) -> Result<u32, String> {
match input.parse::<u32>() {
Ok(n) => Ok(n),
Err(_) => Ok(0),
}
}
fn after(input: &str) -> Result<u32, String> {
input.parse::<u32>().map_err(|e| e.to_string()).or_else(|_| Ok(0))
}Key takeaways
Result combinators let you reshape error paths locally while preserving the clarity of the success path.
The main ideas from this page are:
map_errtransforms error values while leaving success untouchedor_elsehandles an error by running another computation that may succeed or fail- good error shaping often happens at boundaries where low-level failures need higher-level meaning
- combinators are especially useful for adding small local context and normalizing lower-level errors
- explicit branching is often better when the error-handling logic becomes the main story
- pairing
map_errwith?is one of the most common and readable real-world patterns in Rust
Good Rust error handling does not feel pasted on after the fact. It feels like part of the function's design.
