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.
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