Home C / C++ C++23 std::expected and std::optional Monadic Operations: A Practical Guide
Advanced 3 min · July 13, 2026

C++23 std::expected and std::optional Monadic Operations: A Practical Guide

Master C++23 monadic operations for std::expected and std::optional.

N
Naren Founder & Principal Engineer

20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 13, 2026
last updated
2,073
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of C++ templates and lambda expressions
  • Familiarity with std::optional and std::expected
  • Understanding of functional programming concepts (map, flatMap) is helpful but not required
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Monadic operations (map, and_then, or_else) allow chaining operations on std::optional and std::expected without manual error checking.
  • std::optional represents a value that may or may not be present; monadic operations simplify handling missing values.
  • std::expected represents a value or an error; monadic operations propagate errors automatically.
  • These operations reduce boilerplate and improve code readability and safety.
  • C++23 introduced these operations, making functional-style error handling idiomatic.
✦ Definition~90s read
What is C++23 std?

C++23 monadic operations (map, and_then, or_else) are methods on std::optional and std::expected that let you chain operations on values without manual unwrapping, enabling concise and robust error handling.

Think of std::optional as a box that either contains a gift or is empty.
Plain-English First

Think of std::optional as a box that either contains a gift or is empty. Monadic operations let you transform the gift (map), perform another box-opening step (and_then), or provide a default gift if empty (or_else). std::expected is like a box that either contains a gift or a note explaining why it's missing. Monadic operations let you transform the gift, chain steps that might fail, or handle the error note gracefully.

Error handling in C++ has traditionally been a mix of exceptions, error codes, and boolean flags. While exceptions work well for exceptional cases, they are not suitable for expected errors or performance-critical code. Error codes lead to repetitive if-else checks that obscure the main logic. C++17 introduced std::optional and std::variant, and C++23 added std::expected, but the real game-changer is the monadic operations (map, and_then, or_else) that allow functional-style composition. These operations let you chain operations on optional or expected values without manual unwrapping, making code more concise, readable, and less error-prone. In this tutorial, we'll explore how to use these operations in production scenarios, from parsing configuration files to handling network responses. You'll learn the syntax, see real-world examples, and understand common pitfalls. By the end, you'll be able to write robust, expressive error-handling code that scales with your application's complexity.

What are Monadic Operations?

Monadic operations are functional programming patterns that allow you to chain operations on values wrapped in a context (like optional or expected) without manually unwrapping. The three core operations are: - map: Transform the contained value if present, otherwise return an empty context. - and_then: Apply a function that returns a new context (e.g., optional) to the contained value; if empty, propagate the empty. - or_else: Provide an alternative context if the current one is empty.

These operations eliminate the need for explicit if-else checks, making code more declarative and less error-prone. In C++23, std::optional and std::expected gained these methods, bringing functional error handling to the mainstream.

monadic_intro.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <optional>
#include <iostream>

std::optional<int> parse_int(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (...) {
        return std::nullopt;
    }
}

int main() {
    std::optional<std::string> input = "42";
    // Without monadic:
    if (input) {
        auto parsed = parse_int(*input);
        if (parsed) {
            std::cout << *parsed * 2 << '\n';
        }
    }
    // With monadic:
    input
        .and_then(parse_int)
        .map([](int x) { return x * 2; })
        .or_else([] { std::cout << "Failed\n"; return std::optional<int>(0); });
    return 0;
}
Output
84
84
🔥Monadic vs Traditional
📊 Production Insight
In production, prefer monadic chains over nested ifs for readability and maintainability.
🎯 Key Takeaway
Monadic operations (map, and_then, or_else) let you chain operations on optional/expected values without manual checks.

std::optional Monadic Operations

std::optional gained three monadic methods in C++23: map, and_then, and or_else. Let's explore each with examples.

map transforms the contained value using a callable that returns a non-optional value. If the optional is empty, map returns an empty optional.

and_then is similar but the callable must return an optional (or a type convertible to optional). This is useful for chaining operations that may fail.

or_else provides a fallback optional if the current one is empty. The callable returns an optional of the same type.

These methods work with lambdas, function pointers, or any callable. They are constexpr and noexcept where possible.

optional_monadic.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <optional>
#include <iostream>
#include <string>

std::optional<int> half(int x) {
    if (x % 2 == 0) return x / 2;
    return std::nullopt;
}

int main() {
    std::optional<int> opt = 10;
    // map: transform value
    auto doubled = opt.map([](int x) { return x * 2; });
    std::cout << "doubled: " << *doubled << '\n'; // 20

    // and_then: chain failing operation
    auto result = opt.and_then(half); // 10 -> 5
    std::cout << "half: " << *result << '\n'; // 5

    // and_then on odd number
    std::optional<int> odd = 7;
    auto failed = odd.and_then(half); // nullopt
    std::cout << "failed has value: " << failed.has_value() << '\n'; // 0

    // or_else: fallback
    auto fallback = failed.or_else([] { return std::optional<int>(0); });
    std::cout << "fallback: " << *fallback << '\n'; // 0

    // chaining all three
    auto chain = std::optional<int>(12)
                    .and_then(half)   // 6
                    .map([](int x) { return x * 3; }) // 18
                    .or_else([] { return std::optional<int>(-1); });
    std::cout << "chain: " << *chain << '\n'; // 18

    return 0;
}
Output
doubled: 20
half: 5
failed has value: 0
fallback: 0
chain: 18
💡Use and_then for fallible transformations
📊 Production Insight
Prefer and_then over map when the lambda can fail; this avoids nested optionals.
🎯 Key Takeaway
std::optional monadic methods enable clean chaining of operations that may produce or consume optional values.

std::expected Monadic Operations

std::expected<T, E> represents either a value of type T or an error of type E. C++23 added monadic operations: map, and_then, or_else, and also transform_error (which maps the error).

  • map: Transform the value if present; if error, propagate the error unchanged.
  • and_then: Apply a function that returns a new expected; if error, propagate.
  • or_else: Handle the error by providing a new expected (value or error).
  • transform_error: Transform the error (like map but on the error side).

These operations make error handling composable and reduce boilerplate.

expected_monadic.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <expected>
#include <iostream>
#include <string>
#include <charconv>

enum class ParseError { InvalidInput, OutOfRange };

std::expected<int, ParseError> parse_int(const std::string& s) {
    int value;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec == std::errc()) {
        return value;
    } else if (ec == std::errc::result_out_of_range) {
        return std::unexpected(ParseError::OutOfRange);
    } else {
        return std::unexpected(ParseError::InvalidInput);
    }
}

int main() {
    std::expected<int, ParseError> exp = parse_int("42");
    // map: transform value
    auto doubled = exp.map([](int x) { return x * 2; });
    std::cout << "doubled: " << *doubled << '\n'; // 84

    // and_then: chain another parse
    auto chained = exp.and_then([](int x) {
        return parse_int(std::to_string(x));
    });
    std::cout << "chained: " << *chained << '\n'; // 42

    // or_else: handle error
    auto bad = parse_int("abc");
    auto handled = bad.or_else([](ParseError e) -> std::expected<int, ParseError> {
        std::cout << "Error: " << static_cast<int>(e) << '\n';
        return 0; // fallback value
    });
    std::cout << "handled: " << *handled << '\n'; // 0

    // transform_error: map error
    auto mapped_error = bad.transform_error([](ParseError e) {
        return std::string("Parse error code: ") + std::to_string(static_cast<int>(e));
    });
    // mapped_error is std::expected<int, std::string>
    if (!mapped_error) {
        std::cout << mapped_error.error() << '\n'; // Parse error code: 0
    }

    return 0;
}
Output
doubled: 84
chained: 42
Error: 0
handled: 0
Parse error code: 0
⚠ Error types should be meaningful
📊 Production Insight
In high-throughput systems, std::expected with monadic operations can replace exceptions for control flow, improving performance and predictability.
🎯 Key Takeaway
std::expected monadic operations allow chaining computations that may fail, with error propagation and handling.

Practical Example: Configuration Parser

Let's build a configuration parser that reads key-value pairs from a string. We'll use std::expected to handle parsing errors and std::optional for optional keys. Monadic operations will chain the parsing steps cleanly.

We define a Config struct with fields that may be optional. The parser returns std::expected<Config, std::string> on error. We'll use and_then to chain field parsing and map to transform values.

config_parser.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <expected>
#include <optional>
#include <string>
#include <unordered_map>
#include <iostream>

struct Config {
    std::optional<int> timeout;
    std::optional<std::string> host;
    int port;
};

std::expected<int, std::string> parse_int(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (...) {
        return std::unexpected("Invalid integer: " + s);
    }
}

std::expected<Config, std::string> parse_config(const std::unordered_map<std::string, std::string>& kv) {
    Config cfg;
    // Parse port (required)
    auto port_it = kv.find("port");
    if (port_it == kv.end()) {
        return std::unexpected("Missing required key: port");
    }
    auto port_exp = parse_int(port_it->second);
    if (!port_exp) {
        return std::unexpected(port_exp.error());
    }
    cfg.port = *port_exp;

    // Parse timeout (optional)
    auto timeout_it = kv.find("timeout");
    if (timeout_it != kv.end()) {
        auto timeout_exp = parse_int(timeout_it->second)
            .and_then([](int t) -> std::expected<int, std::string> {
                if (t < 0) return std::unexpected("Timeout must be non-negative");
                return t;
            });
        if (!timeout_exp) {
            return std::unexpected(timeout_exp.error());
        }
        cfg.timeout = *timeout_exp;
    }

    // Parse host (optional)
    auto host_it = kv.find("host");
    if (host_it != kv.end()) {
        cfg.host = host_it->second;
    }

    return cfg;
}

int main() {
    auto cfg = parse_config({{"port", "8080"}, {"timeout", "30"}});
    if (cfg) {
        std::cout << "Port: " << cfg->port << '\n';
        if (cfg->timeout) std::cout << "Timeout: " << *cfg->timeout << '\n';
    } else {
        std::cout << "Error: " << cfg.error() << '\n';
    }

    // Missing port
    auto bad = parse_config({{"timeout", "-5"}});
    if (!bad) {
        std::cout << "Error: " << bad.error() << '\n';
    }
    return 0;
}
Output
Port: 8080
Timeout: 30
Error: Missing required key: port
🔥Composing monadic operations
📊 Production Insight
In production, consider using std::expected with a custom error type (e.g., variant or struct) to carry more context about failures.
🎯 Key Takeaway
Monadic operations make parsing and validation code linear and easy to follow, even with multiple optional fields.

Common Pitfalls and Best Practices

  1. Overusing map when and_then is needed: If your lambda returns an optional/expected, use and_then, not map. map would wrap it in a nested optional (optional<optional<T>>).
  2. Ignoring error types: For std::expected, always consider the error type. Using a generic string may lose information. Prefer enums or small structs.
  3. Performance: Monadic operations are generally zero-cost abstractions, but beware of capturing large objects in lambdas. Use std::move when appropriate.
  4. Exception safety: Lambdas should not throw; if they do, the behavior is undefined for std::optional (it will call std::terminate). For std::expected, exceptions propagate normally.
  5. Readability: Long chains can become hard to read. Break them into named lambdas or intermediate variables.
Best practices
  • Use and_then for fallible operations, map for infallible transformations.
  • Use or_else to provide fallbacks or log errors.
  • For std::expected, use transform_error to convert errors to a common type.
  • Avoid mixing std::optional and std::expected in the same chain; stick to one type.
pitfalls.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <optional>
#include <iostream>

// Pitfall 1: map with optional return
std::optional<int> maybe_double(int x) { return x * 2; }

int main() {
    std::optional<int> opt = 5;
    // Wrong: returns optional<optional<int>>
    auto nested = opt.map(maybe_double);
    // Correct: use and_then
    auto flat = opt.and_then(maybe_double);
    std::cout << "flat: " << *flat << '\n'; // 10

    // Pitfall 2: throwing lambda
    auto bad = opt.map([](int) { throw std::runtime_error(""); });
    // This will call std::terminate if opt has value
    // Avoid throwing in monadic lambdas

    return 0;
}
Output
flat: 10
⚠ Don't throw in monadic lambdas
📊 Production Insight
In production, enforce no-throw lambdas with static analysis or by marking lambdas noexcept.
🎯 Key Takeaway
Use and_then for optional-returning lambdas, avoid throwing, and prefer meaningful error types.

Performance Considerations

Monadic operations are designed to be zero-cost abstractions. The compiler can inline the lambdas and optimize away the optional/expected wrappers. However, there are cases where performance can degrade:

  • Copying large objects: If your optional/expected wraps a large type, each monadic operation may copy it. Use std::move in lambdas or store by pointer.
  • Multiple chaining: Each step adds a branch (check if has value). For very long chains, consider restructuring.
  • Error handling: std::expected stores an error even on the value path; this may increase size. Use std::optional if no error info is needed.

Benchmarking is key. In most cases, the readability gains outweigh any minor performance cost.

performance.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <optional>
#include <vector>
#include <iostream>

struct BigData {
    std::vector<int> data;
    // assume large
};

int main() {
    std::optional<BigData> opt = BigData{{1,2,3}};
    // Avoid copies: use std::move in lambda
    auto result = std::move(opt).map([](BigData&& b) {
        b.data.push_back(4);
        return std::move(b);
    });
    // Now opt is empty, result owns the data
    if (result) {
        std::cout << "Size: " << result->data.size() << '\n'; // 4
    }
    return 0;
}
Output
Size: 4
💡Use std::move to avoid copies
📊 Production Insight
Profile your hot paths. In many cases, monadic chains are as fast as hand-written if-else.
🎯 Key Takeaway
Monadic operations are efficient but be mindful of copies for large types; use move semantics.

Migrating from Legacy Code

Many codebases use error codes or exceptions. Migrating to std::expected and monadic operations can improve safety and readability. Here's a step-by-step approach:

  1. Identify functions that return bool + out parameter or throw on expected errors.
  2. Replace with std::expected<T, ErrorCode>.
  3. Replace nested if-else chains with monadic operations.
  4. Use or_else for error handling and logging.

Example: A function that parses a number and returns bool with an int reference.

migration.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <expected>
#include <iostream>
#include <string>

// Legacy function
bool parse_int_legacy(const std::string& s, int& out) {
    try {
        out = std::stoi(s);
        return true;
    } catch (...) {
        return false;
    }
}

// Modern version
std::expected<int, std::string> parse_int_modern(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (const std::invalid_argument&) {
        return std::unexpected(std::string("Invalid argument: ") + s);
    } catch (const std::out_of_range&) {
        return std::unexpected(std::string("Out of range: ") + s);
    }
}

int main() {
    // Legacy usage
    int val;
    if (parse_int_legacy("42", val)) {
        std::cout << val << '\n';
    }

    // Modern usage with monadic
    parse_int_modern("42")
        .map([](int x) { std::cout << x << '\n'; return x; })
        .or_else([](const std::string& e) {
            std::cerr << "Error: " << e << '\n';
            return std::expected<int, std::string>(0);
        });

    return 0;
}
Output
42
42
🔥Migration strategy
📊 Production Insight
In large codebases, introduce std::expected in new modules first, then refactor legacy code incrementally.
🎯 Key Takeaway
Migrating to std::expected and monadic operations improves error handling clarity and reduces bugs.
● Production incidentPOST-MORTEMseverity: high

The Silent Crash: Missing Configuration Key

Symptom
Users experienced intermittent 500 errors and service restarts with no clear error logs.
Assumption
The developer assumed that all configuration keys were present and valid, so they used .value() on std::optional without checking.
Root cause
A new deployment removed a configuration key, causing std::optional::value() to throw std::bad_optional_access, which was not caught, leading to std::terminate.
Fix
Replace .value() with monadic operations (and_then, or_else) to handle missing keys gracefully and log meaningful errors.
Key lesson
  • Always handle missing optional values explicitly; avoid .value() without checks.
  • Use monadic operations to chain operations and propagate errors naturally.
  • Log errors with context to aid debugging.
  • Prefer std::expected over std::optional when error details are needed.
  • Test with missing/invalid inputs to ensure robustness.
Production debug guideSymptom to Action5 entries
Symptom · 01
std::optional::value() throws std::bad_optional_access
Fix
Replace .value() with .value_or() or monadic operations; check if optional is empty before accessing.
Symptom · 02
std::expected::value() throws std::bad_expected_access
Fix
Use .error() to inspect the error; replace with monadic operations to handle errors gracefully.
Symptom · 03
Chain of and_then calls returns unexpected empty optional
Fix
Add logging inside lambdas to trace which step failed; use or_else to provide fallback.
Symptom · 04
map lambda not called when optional is empty
Fix
Verify that map is only called on non-empty optionals; use and_then if you need to return an optional.
Symptom · 05
Performance degradation due to many monadic calls
Fix
Check for unnecessary copies; use std::move in lambdas; profile with tools like perf.
★ Quick Debug Cheat SheetCommon symptoms and immediate actions for monadic operations.
std::optional::value() throws
Immediate action
Check if optional is empty before calling value()
Commands
if (opt) { use opt.value(); }
opt.value_or(default);
Fix now
Replace with monadic: opt.and_then(...).or_else(...)
std::expected::value() throws+
Immediate action
Inspect error with .error()
Commands
if (!exp) { log(exp.error()); }
exp.or_else([](auto e) { log(e); return 0; });
Fix now
Use .or_else() to handle error and provide fallback.
Chain returns unexpected empty+
Immediate action
Add logging in each lambda
Commands
opt.and_then([](auto v) { log("step1"); return ...; })
opt.and_then(...).or_else([] { log("failed"); return ...; });
Fix now
Use or_else to catch failure and provide default.
Operationstd::optionalstd::expectedDescription
mapTransforms value, returns optionalTransforms value, returns expectedInfallible transformation
and_thenTransforms value, returns optionalTransforms value, returns expectedFallible transformation
or_elseProvides fallback optionalHandles error, returns expectedFallback/error handling
transform_errorN/ATransforms errorError transformation
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
monadic_intro.cppstd::optional parse_int(const std::string& s) {What are Monadic Operations?
optional_monadic.cppstd::optional half(int x) {std
expected_monadic.cppenum class ParseError { InvalidInput, OutOfRange };std
config_parser.cppstruct Config {Practical Example
pitfalls.cppstd::optional maybe_double(int x) { return x * 2; }Common Pitfalls and Best Practices
performance.cppstruct BigData {Performance Considerations
migration.cppbool parse_int_legacy(const std::string& s, int& out) {Migrating from Legacy Code

Key takeaways

1
Monadic operations (map, and_then, or_else) on std::optional and std::expected enable functional-style error handling without boilerplate.
2
Use and_then for fallible transformations, map for infallible ones, and or_else for fallbacks.
3
Prefer std::expected over std::optional when error details are needed; use meaningful error types.
4
Avoid throwing in monadic lambdas for std::optional; use std::expected for error propagation.
5
Monadic operations are zero-cost abstractions; use std::move to avoid copies of large objects.

Common mistakes to avoid

5 patterns
×

Using map when the lambda returns an optional

×

Calling .value() on optional/expected without checking

×

Throwing exceptions inside monadic lambdas for std::optional

×

Ignoring the error type in std::expected

×

Chaining too many operations without readability

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the monadic operations available on std::optional in C++23.
Q02SENIOR
What is the difference between map and and_then on std::expected?
Q03SENIOR
How would you implement a chain of operations that parses an integer fro...
Q04JUNIOR
What happens if you throw an exception inside a lambda passed to std::op...
Q05SENIOR
Compare error handling with exceptions vs std::expected monadic operatio...
Q01 of 05SENIOR

Explain the monadic operations available on std::optional in C++23.

ANSWER
std::optional has three monadic operations: map (transforms value, returns optional), and_then (transforms value with a function returning optional), and or_else (provides fallback optional if empty).
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between map and and_then on std::optional?
02
Can I use monadic operations with std::optional before C++23?
03
When should I use std::expected instead of std::optional?
04
Are monadic operations noexcept?
05
How do I handle errors in a chain of and_then calls?
N
Naren Founder & Principal Engineer

20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 13, 2026
last updated
2,073
articles · all by Naren
🔥

That's C++ Advanced. Mark it forged?

3 min read · try the examples if you haven't

Previous
C++20/23 Format Library
24 / 41 · C++ Advanced
Next
std::span and std::string_view in Modern C++