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.

  • map transforms the success value
  • map_err transforms the error value
  • and_then sequences another fallible success step
  • or_else sequences 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_err when you want to transform one error value into another error value
  • use or_else when 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_err handles failure shape
  • map handles 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 run

This 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 test

cargo 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:

  1. a match exists only to turn one error type into another
  2. a lower-level error needs local context such as a path, field name, or setting name
  3. several low-level errors should appear as one outward error type
  4. 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_err transforms error values while leaving success untouched
  • or_else handles 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_err with ? 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.