Monadic Operations
Compose pipelines over std::optional and std::expected using and_then, transform, and or_else without manual value checks at each step.
Monadic Operationssince C++23Member functions on std::optional and std::expected that conditionally apply a callable to a contained value, enabling declarative pipelines of fallible operations without explicit emptiness or error checks between steps.
Overview
Before C++23, chaining operations through std::optional meant nesting conditionals or breaking the chain into explicit if blocks at every step:
std::optional<std::string> raw = get_raw_input();
std::optional<int> parsed;
std::optional<double> result;
if (raw) parsed = try_parse(*raw);
if (parsed && *parsed >= 0) result = std::sqrt(static_cast<double>(*parsed));C++23 adds three member functions to std::optional<T> that eliminate this pattern:
| Operation | Callable signature | Result type |
|---|---|---|
and_then(f) | T β optional<U> | optional<U> |
transform(f) | T β U | optional<U> |
or_else(f) | () β optional<T> | optional<T> |
and_then is a flatmap β the callable must itself produce an optional, so the operation stays monomorphic and you never accidentally produce optional<optional<T>>. transform lifts a plain function into the optional context. or_else runs only when the optional is empty, producing a fallback.
std::expected<T, E> (also C++23) provides the same three plus a fourth:
| Operation | Callable signature | Result type |
|---|---|---|
and_then(f) | T β expected<U, E> | expected<U, E> |
transform(f) | T β U | expected<U, E> |
or_else(f) | E β expected<T, F> | expected<T, F> |
transform_error(f) | E β F | expected<T, F> |
transform_error maps the error channel without touching the value β essential when stitching subsystems that use different error enumerations.
Syntax
Each operation is overloaded across all four value categories of *this so value propagation is always correct:
// std::optional β representative declarations (C++23)
template<class T>
class optional {
template<class F> constexpr auto and_then(F&&) &; // f(T&) -> optional<U>
template<class F> constexpr auto and_then(F&&) const&; // f(const T&) -> optional<U>
template<class F> constexpr auto and_then(F&&) &&; // f(T&&) -> optional<U>
template<class F> constexpr auto and_then(F&&) const&&; // f(const T&&)-> optional<U>
template<class F> constexpr auto transform(F&&) &; // f(T&) -> U
// ... same const/rvalue overloads
template<class F> constexpr optional or_else(F&&) const&; // f() -> optional<T>
template<class F> constexpr optional or_else(F&&) &&;
};Internally, each operation is equivalent to: invoke the callable on the contained value if present, otherwise propagate the empty state. No heap allocation, no exception for the empty case.
Examples
Parsing pipeline on std::optional
#include <optional>
#include <string_view> // C++17
#include <charconv> // C++17
#include <cmath>
std::optional<int> parse_int(std::string_view sv) {
int v{};
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), v); // C++17
if (ec == std::errc{}) return v;
return std::nullopt;
}
std::optional<double> safe_sqrt(int n) {
if (n < 0) return std::nullopt;
return std::sqrt(static_cast<double>(n));
}
// C++23: chain reads left-to-right; each empty propagates silently.
std::optional<double> process(std::string_view input) {
return parse_int(input) // optional<int>
.and_then(safe_sqrt) // optional<double>
.transform([](double d) { // optional<double>
return std::round(d * 1000.0) / 1000.0;
});
}
// process("144") -> optional{12.0}
// process("-9") -> nullopt (safe_sqrt rejects)
// process("???") -> nullopt (parse_int fails)Fallback chains with or_else
or_else takes a nullary callable (no argument) and is the right tool for priority-ordered lookups:
#include <optional>
#include <string>
std::optional<std::string> from_env(std::string_view key);
std::optional<std::string> from_config(std::string_view key);
std::string resolve(std::string_view key) {
return from_env(key)
.or_else([&]{ return from_config(key); }) // C++23
.value_or(std::string{"default"});
}std::expected with unified error types
Real pipelines often cross subsystem boundaries that define their own error codes. transform_error converts the error channel at the boundary so the chain stays uniform:
#include <expected> // C++23
#include <string>
#include <variant> // C++17
enum class ParseError { empty, bad_format, overflow };
enum class DomainError { negative, too_large };
std::expected<int, ParseError> parse(std::string_view);
std::expected<double, DomainError> compute(int);
std::expected<double, std::string> full_pipeline(std::string_view s) {
return parse(s)
.transform_error([](ParseError e) -> std::string { // C++23
switch (e) {
case ParseError::empty: return "empty input";
case ParseError::bad_format: return "bad format";
case ParseError::overflow: return "value overflowed";
}
std::unreachable(); // C++23
})
.and_then([](int n) -> std::expected<double, std::string> { // C++23
return compute(n)
.transform_error([](DomainError e) -> std::string {
return e == DomainError::negative
? "negative not allowed" : "value too large";
});
});
}Batch processing with sentinel fallback
#include <optional>
#include <vector>
#include <string>
struct Record { std::string id; double value; };
std::optional<Record> fetch(int id);
std::optional<double> validate(const Record& r);
double normalize(double v);
std::vector<double> process_batch(const std::vector<int>& ids) {
std::vector<double> out;
out.reserve(ids.size());
for (int id : ids) {
out.push_back(
fetch(id)
.and_then(validate) // C++23
.transform(normalize) // C++23
.or_else([]{ return std::optional{0.0}; }) // C++23 β sentinel for missing
.value()
);
}
return out;
}Best Practices
Match the operation to the callable's return type. Use and_then when the function already returns an optional or expected (it would otherwise produce a doubly-wrapped type). Use transform when the function returns a plain value. Mixing them up produces a compile error for and_then and a redundant wrapping for transform β both are diagnosable.
Convert error types at subsystem boundaries, not inside and_then bodies. Calling transform_error immediately after crossing an API boundary keeps every subsequent and_then lambda type-uniform and free of inline error-mapping noise.
Keep chain links single-purpose. A callable that does three things inside an and_then is a sign the step should be split. Short lambdas or free functions named for what they do make the chain read like a specification.
Do not use transform for side effects. If you only want to observe the value β log it, record a metric β use if (opt) outside the chain. A transform that returns the same type unchanged mutates nothing but creates a copy on some value categories and misleads readers about the pipeline's shape.
Common Pitfalls
Passing a T β U function to and_then is ill-formed. The error messages from deep inside std::invoke_result_t specializations are long. The fix is always to switch to transform:
std::optional<int> opt = 42;
// Ill-formed: and_then requires T -> optional<U>
auto bad = opt.and_then([](int n) { return n * 2; });
// Correct
auto good = opt.transform([](int n) { return n * 2; }); // C++23optional::or_else takes a nullary callable; expected::or_else takes a unary callable that receives the error. The asymmetry is intentional β optional has no error value to pass β but it means you cannot copy a lambda between the two without adjusting its signature.
Chaining on rvalues moves the contained value. After std::move(opt).and_then(f), the original opt is valid but unspecified. This is fine in pipelines where the source is temporary, but not if you need to inspect opt after the chain.
Compiler and library support requires C++23. GCC 13+, Clang 17+, and MSVC 19.38+ ship the necessary standard library implementations. Enabling -std=c++23 (or /std:c++latest) on an older toolchain will not backfill these member functions. There is no portable polyfill that preserves the exact member-function syntax; third-party wrapper types (such as those in tl::optional) provide similar chaining on C++17 with a slightly different API surface.
The std::optional Monadic API in Depth (P0798)
P0798 β adopted for C++23 β added three member functions to std::optional. Their semantics follow directly from monad theory but are expressed in practical C++ terms.
and_then β flatmap over the value
and_then(f) requires that f returns an optional. If the current optional holds a value, f is called with it and the result is returned as-is (no extra wrapping). If the optional is empty, nullopt propagates without calling f.
The "must return optional" constraint is what prevents optional<optional<T>>. If f returned a plain T, you would accumulate wrapping at every step; and_then keeps the nesting flat.
#include <optional>
#include <string_view>
#include <charconv>
std::optional<int> to_positive(int n) {
if (n > 0) return n;
return std::nullopt;
}
std::optional<double> reciprocal(int n) {
if (n == 0) return std::nullopt;
return 1.0 / n;
}
// C++23: each step is a "T -> optional<U>" function
auto result = std::optional<std::string_view>{"42"}
.and_then([](std::string_view sv) -> std::optional<int> {
int v{};
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), v);
return (ec == std::errc{}) ? std::optional{v} : std::nullopt;
})
.and_then(to_positive) // propagates nullopt if parse failed
.and_then(reciprocal); // propagates nullopt if not positive
// result: optional<double>{0.0238...} for "42"
// result: nullopt for "-5" or "xyz"transform β map over the value without flattening
transform(f) requires that f returns a plain U, not an optional<U>. The library wraps the return value. If the optional is empty, the result is optional<U>{} β the correct empty type β without calling f.
#include <optional>
#include <string>
#include <algorithm>
#include <cctype>
std::optional<std::string> username = get_username();
// transform chains plain T->U functions; no manual wrapping
auto display = username
.transform([](std::string s) {
// capitalise first letter
if (!s.empty()) s[0] = static_cast<char>(std::toupper(s[0]));
return s;
})
.transform([](std::string s) {
return "@" + s;
});
// display: optional{"@Alice"} or nullopt β never optional<optional<string>>A common mistake is to use transform with a function that already returns optional<U>. The result will be optional<optional<U>>, which is almost never intended. The compiler will not warn β it is well-formed, just wrong.
or_else β recover from the empty case
or_else(f) is the dual of and_then. It calls f (which takes no argument) only when the optional is empty, expecting f to return an optional<T>. If the optional holds a value, it propagates unchanged.
#include <optional>
#include <string>
std::optional<std::string> read_env(const char* var);
std::optional<std::string> read_config(const char* key);
std::string get_api_key() {
return read_env("API_KEY")
.or_else([&]{ return read_config("api.key"); }) // C++23
.or_else([&]{ return read_env("FALLBACK_KEY"); }) // C++23
.value_or("demo-key");
}The chain short-circuits on the first non-empty optional β later fallbacks are never called. This priority-lookup pattern is idiomatic for configuration resolution.
Complete std::optional pipeline
#include <optional>
#include <string>
#include <string_view>
#include <charconv>
#include <cmath>
struct Config { int max_items; };
std::optional<std::string_view> env_get(std::string_view key);
std::optional<Config> config_get(std::string_view key);
std::optional<int> resolve_limit(std::string_view key) {
return env_get(key)
// parse the string to int
.and_then([](std::string_view sv) -> std::optional<int> {
int v{};
auto [p, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), v);
return (ec == std::errc{}) ? std::optional{v} : std::nullopt;
})
// reject non-positive values
.and_then([](int v) -> std::optional<int> {
return (v > 0) ? std::optional{v} : std::nullopt;
})
// clamp to a safe maximum
.transform([](int v) { return std::min(v, 10'000); })
// fall back to config if env was absent or invalid
.or_else([&]() -> std::optional<int> {
return config_get(key)
.transform([](const Config& c) { return c.max_items; });
});
}The std::expected Monadic API in Depth (P2505)
std::expected<T, E> provides four operations instead of three. The extra operation, transform_error, maps the error channel β a capability that has no analogue in std::optional because optional carries no error payload.
The four operations side by side
#include <expected> // C++23
enum class IoError { NotFound, PermissionDenied, Timeout };
enum class AppError { InvalidInput, QuotaExceeded };
std::expected<std::string, IoError> read_file(const char* path);
std::expected<int, AppError> parse_count(std::string_view data);
// Imagine we need to bridge IoError -> AppError for a uniform return type.
// transform_error converts the error at the boundary; the value is untouched.
std::expected<int, AppError> load_count(const char* path) {
return read_file(path)
// Step 1: translate IoError -> AppError (error channel only)
.transform_error([](IoError e) -> AppError {
return (e == IoError::NotFound)
? AppError::InvalidInput
: AppError::QuotaExceeded; // map both PermDenied and Timeout
})
// Step 2: parse the string contents (value channel, may produce new error)
.and_then([](const std::string& s) {
return parse_count(s); // returns expected<int, AppError>
})
// Step 3: clamp the value (cannot fail β use transform)
.transform([](int n) { return std::min(n, 1'000); });
}The rule of thumb mirrors std::optional:
and_then(f)whenfreturnsexpected<U, E>(may fail)transform(f)whenfreturns a plainU(cannot fail)or_else(f)whenfhandles the error and returnsexpected<T, F>transform_error(f)whenfremaps the error type and returns a plainF
Chaining without nested if/else
Before C++23, chaining multiple fallible operations required explicit unwrapping at each step:
// Pre-C++23 style β reading control flow requires tracking indentation
std::expected<Report, std::string> generate_report_old(int user_id) {
auto user = fetch_user(user_id);
if (!user) return std::unexpected("user not found: " + std::to_string(user_id));
auto data = fetch_data(user->account_id);
if (!data) return std::unexpected("data error: " + data.error());
auto processed = process(*data);
if (!processed) return std::unexpected("process error: " + processed.error());
return build_report(*user, *processed);
}With C++23 monadic operations, the same logic reads left-to-right without indentation growth:
// C++23 style β one linear expression
std::expected<Report, std::string> generate_report(int user_id) {
return fetch_user(user_id)
.transform_error([&](auto& e) {
return "user not found: " + std::to_string(user_id);
})
.and_then([](const User& u) {
return fetch_data(u.account_id);
})
.and_then([](const Data& d) {
return process(d);
})
.and_then([&](const Processed& p) -> std::expected<Report, std::string> {
// fetch_user result is gone by now β capture by value if needed
return build_report(p);
});
}The error short-circuits at the first failure; subsequent and_then lambdas are never called. No explicit if (!x) return std::unexpected(...) is needed after the first step.
Functor and Monad Laws
The correctness of monadic pipelines rests on three algebraic laws. C++ does not enforce these laws at compile time, but the standard library's implementations of transform and and_then satisfy them by construction. Understanding the laws tells you what refactorings preserve semantics.
Identity law (functor)
Transforming with the identity function produces an equivalent optional:
// For any optional<T> opt:
// opt.transform([](T x){ return x; }) == opt
//
// Consequence: you can safely add or remove identity transform steps
// during refactoring without changing the pipeline's result.
std::optional<int> x = 42;
auto y = x.transform([](int n){ return n; }); // still optional{42}Composition law (functor)
Two consecutive transform calls are equivalent to a single transform that composes the functions:
// opt.transform(f).transform(g) == opt.transform([](T x){ return g(f(x)); })
//
// Consequence: you can split a complex transform into smaller steps for
// readability, or merge steps for performance, without changing the result.
std::optional<int> opt = 10;
auto a = opt.transform([](int n){ return n * 2; })
.transform([](int n){ return n + 1; }); // 21
auto b = opt.transform([](int n){ return n * 2 + 1; }); // also 21
// a == b guaranteed by the composition lawLeft and right identity for and_then (monad)
These laws govern how and_then interacts with wrapping (the "return" or "unit" operation in monad vocabulary β std::optional{v} / std::expected<T,E>{v}):
// Left identity: wrapping a value then flatmapping f is the same as calling f directly.
// optional{v}.and_then(f) == f(v)
//
// Right identity: flatmapping with the wrapper is a no-op.
// opt.and_then([](T x){ return optional{x}; }) == opt
//
// Consequence: you can freely introduce or remove trivial wrapping steps
// in a pipeline β the outer structure is preserved.
std::optional<int> opt = 5;
auto wrapped = opt.and_then([](int n){ return std::optional{n}; }); // still optional{5}Practical meaning of the laws
The laws collectively guarantee that the order in which you choose to group or split monadic steps does not affect the result. A pipeline of ten and_then steps can be refactored into five steps that call helpers β or merged into one β without changing observable behaviour. This is the property that makes monadic code safe to refactor.
std::ranges as a Monad
Ranges (C++20) participate in the same categorical structure. A range is a container of zero or more values; functors and monads over ranges generalise the optional pattern from "zero or one" to "zero or more".
Functor: views::transform
views::transform maps a function over every element, producing a new range of the same length. This is the range analogue of optional::transform:
#include <ranges>
#include <vector>
#include <string>
#include <cctype>
std::vector<std::string> words = {"hello", "world", "cpp"};
// Lazy: no allocation until iteration
auto upper = words
| std::views::transform([](std::string s) {
std::ranges::transform(s, s.begin(), ::toupper);
return s;
});
// upper lazily yields: "HELLO", "WORLD", "CPP"Monad: views::join after views::transform
views::join after views::transform is the range equivalent of and_then β it flatmaps a function that produces a range over each element:
#include <ranges>
#include <vector>
#include <string>
// Each word expands to its individual characters (a range of ranges -> flat range)
std::vector<std::string> words = {"hi", "bye"};
auto chars = words
| std::views::transform([](const std::string& s) -> const std::string& { return s; })
| std::views::join;
// chars lazily yields: 'h','i','b','y','e'
// C++23: views::join_with inserts a separator between inner ranges
auto joined = words | std::views::join_with('-');
// yields: 'h','i','-','b','y','e'Connection to functional programming
| Category | optional | ranges | Haskell analogue |
|---|---|---|---|
| Type constructor | optional<T> | range<T> | Maybe T / [] T |
| Functor map | transform(f) | views::transform(f) | fmap f |
| Monadic flatmap | and_then(f) | transform(f) | join | >>= f |
| Filter | and_then with predicate | views::filter(p) | filter p |
views::filter is itself a partial monad operation β it eliminates elements that do not satisfy a predicate, just as and_then with a predicate-returning function eliminates the optional value. Both express the idea of "keep only what passes".
Custom Monadic Types Before C++23
Before C++23, applications that needed chaining had to implement and_then/transform manually on their own result types. P0798 and P2505 formalised a pattern that had been independently reinvented in many codebases.
// A C++17-compatible Result<T,E> with manual monadic interface
template<typename T, typename E>
class Result {
public:
// Construct success
static Result ok(T value) { Result r; r.value_ = std::move(value); return r; }
// Construct failure
static Result err(E error) { Result r; r.error_ = std::move(error); return r; }
bool has_value() const noexcept { return value_.has_value(); }
const T& value() const { return *value_; }
const E& error() const { return *error_; }
// and_then: f must return Result<U,E>
template<typename F>
auto and_then(F&& f) const& -> std::invoke_result_t<F, const T&> {
using R = std::invoke_result_t<F, const T&>;
if (has_value()) return std::forward<F>(f)(*value_);
return R::err(*error_);
}
// transform: f returns plain U; we wrap it
template<typename F>
auto transform(F&& f) const& {
using U = std::invoke_result_t<F, const T&>;
if (has_value()) return Result<U, E>::ok(std::forward<F>(f)(*value_));
return Result<U, E>::err(*error_);
}
// or_else: f receives the error and returns Result<T,F>
template<typename F>
auto or_else(F&& f) const& -> std::invoke_result_t<F, const E&> {
if (!has_value()) return std::forward<F>(f)(*error_);
return *this;
}
private:
std::optional<T> value_;
std::optional<E> error_;
};Third-party libraries such as tl::expected (by Sy Brand, which directly informed P0798) and outcome::result shipped production-quality implementations of this pattern for C++14/17. The standard library's C++23 additions are essentially the vocabulary-type blessing of tl::expected's API.
Comparison: Exceptions vs optional vs expected
The same three-step pipeline β read a file, parse an integer, compute a square root β can be written with each error-handling strategy. The comparison reveals the trade-offs.
With exceptions
// Exceptions: clean happy path; error type is implicit; cannot be ignored;
// overhead on the cold (error) path depends on the implementation.
double process_exception(const std::string& path) {
std::string content = read_file(path); // throws std::ios_base::failure
int n = parse_int(content); // throws std::invalid_argument
if (n < 0) throw std::domain_error("negative value");
return std::sqrt(static_cast<double>(n));
}
// Caller must wrap in try/catch to observe the error.
// Forgetting the try/catch is valid C++ β the exception propagates silently upward.
try {
double v = process_exception("data.txt");
use(v);
} catch (const std::exception& e) {
report(e.what());
}With std::optional (no error info)
// optional: zero-overhead; forces acknowledgment at every callsite;
// loses the reason for failure β suitable only when "why it failed" is irrelevant.
std::optional<double> process_optional(const std::string& path) {
return read_file_opt(path) // optional<string>
.and_then(parse_int_opt) // optional<int> (C++23)
.and_then([](int n) -> std::optional<double> {
if (n < 0) return std::nullopt;
return std::sqrt(static_cast<double>(n));
}); // optional<double>
}
// Caller knows it might be absent but not why:
if (auto v = process_optional("data.txt"))
use(*v);
else
report("operation failed"); // cannot distinguish file-not-found from parse errorWith std::expected (typed error)
// expected: zero-overhead on success; typed, inspectable error; forces acknowledgment;
// requires defining an error type β worth it when the caller needs to distinguish causes.
enum class PipelineError { FileNotFound, ParseFailed, NegativeValue };
std::expected<double, PipelineError> process_expected(const std::string& path) {
return read_file_exp(path) // expected<string, PipelineError>
.and_then(parse_int_exp) // expected<int, PipelineError> (C++23)
.and_then([](int n) -> std::expected<double, PipelineError> {
if (n < 0) return std::unexpected(PipelineError::NegativeValue);
return std::sqrt(static_cast<double>(n));
}); // expected<double, PipelineError>
}
// Caller can inspect the error and branch on its cause:
auto result = process_expected("data.txt");
if (result) {
use(*result);
} else {
switch (result.error()) {
case PipelineError::FileNotFound: report("file missing"); break;
case PipelineError::ParseFailed: report("bad format"); break;
case PipelineError::NegativeValue: report("need positive"); break;
}
}When to choose each
| Criterion | Exceptions | optional | expected |
|---|---|---|---|
| Error is truly exceptional (OOM, corrupt state) | Yes | No | No |
| Error reason must be communicated to caller | Yes | No | Yes |
| Hot path; overhead of exception setup matters | No | Yes | Yes |
| No-exception environment (embedded, kernel) | No | Yes | Yes |
| Caller can ignore the error accidentally | Yes | No | No |
| Error type changes across subsystem boundaries | Hard | N/A | transform_error |
The idiomatic modern approach is: exceptions for bugs and unrecoverable conditions, std::expected for expected failures with a reason, std::optional only when the reason for absence is genuinely not needed.
See Also
std::optional(cppreference)std::expected(cppreference)reference/idioms/algebraic-typesβ sum types that motivate the monadic interfacereference/idioms/continuation-passingβ an alternative compositional style for deferred or async transforms