std::optional Monadic Operations (C++23)
C++23 and_then, transform, and or_else on std::optional enable composable error-propagation pipelines without nested nullopt checks.
std::optional monadic operationssince C++23Three member functions β and_then, transform, and or_else β that enable functional composition over std::optional by threading values through a chain of operations while short-circuiting on nullopt.
Overview
Before C++23, chaining fallible operations through std::optional required either nested if checks or manual helper functions. C++23 (P0798R8) added three monadic operations modelled on the Maybe monad:
and_thenβ sequences a failable step: callsf(*opt)ifoptholds a value;fmust return anoptional. Returns empty if the input is empty.transformβ maps a total function over the value: callsf(*opt)and wraps the result inoptional.freturns a plain value, not anoptional.or_elseβ provides a fallback when empty: returns*thisunchanged if it holds a value, otherwise callsf()(which must returnoptional<T>).
All three propagate emptiness automatically, so a nullopt at any point in the chain passes through without calling subsequent functions. This is sometimes called railway-oriented programming: the happy path runs straight through; any failure diverts to the empty track.
Each operation provides four ref-qualified overloads (&, const &, &&, const &&) to forward the contained value with the correct value category β important when T is move-only or expensive to copy. or_else has only two overloads (const & and &&) because the callable receives no argument.
Syntax
// and_then β f must return optional<U>
template<class F>
constexpr auto and_then(F&& f) &; // C++23
template<class F>
constexpr auto and_then(F&& f) const &; // C++23
template<class F>
constexpr auto and_then(F&& f) &&; // C++23
template<class F>
constexpr auto and_then(F&& f) const &&; // C++23
// transform β f returns a plain (non-optional, non-reference, non-void) value
template<class F>
constexpr auto transform(F&& f) &;
template<class F>
constexpr auto transform(F&& f) const &;
template<class F>
constexpr auto transform(F&& f) &&;
template<class F>
constexpr auto transform(F&& f) const &&;
// or_else β f takes no arguments, must return optional<T>
template<class F>
constexpr optional or_else(F&& f) const &;
template<class F>
constexpr optional or_else(F&& f) &&;The return type of and_then is remove_cvref_t<invoke_result_t<F, T&>> β whatever f returns. The return type of transform is optional<remove_cv_t<invoke_result_t<F, T&>>>.
Examples
Parsing pipeline
#include <optional>
#include <charconv>
#include <cmath>
#include <string_view>
std::optional<int> parse_int(std::string_view sv) { // C++17
int result{};
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), result);
return ec == std::errc{} ? std::optional{result} : std::nullopt;
}
std::optional<double> safe_sqrt(int n) { // C++17
return n >= 0 ? std::optional{std::sqrt(static_cast<double>(n))} : std::nullopt;
}
// C++23: chain parse β validate β sqrt
auto r1 = std::optional<std::string_view>{"16"}
.and_then(parse_int) // optional{16}
.and_then(safe_sqrt); // optional{4.0}
auto r2 = std::optional<std::string_view>{"abc"}
.and_then(parse_int) // nullopt β short-circuits
.and_then(safe_sqrt); // never called; r2 == nulloptTransforming without introducing optionality
// transform: f returns T, not optional<T>
auto upper_first = std::optional<std::string>{"hello"}
.transform([](std::string s) -> char {
return static_cast<char>(std::toupper(static_cast<unsigned char>(s[0])));
});
// upper_first == optional{'H'}
// Chain transforms β each is a total function
auto formatted = std::optional{42}
.transform([](int n) { return n * 2; }) // optional{84}
.transform([](int n) { return std::to_string(n); }) // optional{"84"}
.transform([](std::string s) { return "[" + s + "]"; }); // optional{"[84]"}Fallback chains with or_else
struct Config { std::string path; };
std::optional<Config> load_from_env();
std::optional<Config> load_from_file(std::string_view path);
std::optional<Config> default_config();
auto config = load_from_env()
.or_else([] { return load_from_file("/etc/myapp/config.toml"); })
.or_else([] { return load_from_file("~/.myapp"); })
.or_else(default_config);
// config is guaranteed to hold a value if default_config() always returns oneReal-world pipeline: request handling
struct Request { int user_id; std::string body; };
struct User { int id; bool active; std::string email; };
struct Profile { std::string display_name; std::string avatar_url; };
std::optional<User> fetch_user(int id);
std::optional<Profile> fetch_profile(const User& u);
// Only active users have profiles exposed
std::optional<Profile> get_active_profile(const Request& req) {
return fetch_user(req.user_id)
.and_then([](const User& u) -> std::optional<User> {
return u.active ? std::optional{u} : std::nullopt;
})
.and_then(fetch_profile);
}
// Without monadic chaining β equivalent but noisier
std::optional<Profile> get_active_profile_old(const Request& req) {
auto user = fetch_user(req.user_id);
if (!user || !user->active) return std::nullopt;
return fetch_profile(*user);
}Best Practices
Prefer transform over and_then for total functions. If f cannot fail, use transform β it communicates intent and avoids the accidental double-wrapping that and_then would require from returning optional<optional<T>>.
Use or_else instead of value_or for lazy fallbacks. value_or(expr) evaluates expr unconditionally; or_else(f) calls f only when the optional is empty. For any fallback that requires a function call or allocation, or_else is strictly better.
// Eager β default_widget() always constructed:
auto w = find_widget(id).value_or(default_widget());
// Lazy β default_widget() called only when needed:
auto w = find_widget(id)
.or_else([] { return std::optional{default_widget()}; })
.value(); // safe here if or_else guarantees non-emptyAnchor value extraction at the end. Call value_or, value, or a final transform after the chain resolves, not in the middle. Extracting early with *opt bypasses the monadic interface and forces you back to manual checks.
Common Pitfalls
and_then with a function returning T instead of optional<T> β this is a hard compile error, but the message can be opaque. If you get a concept-constraint failure mentioning is-optional-like, the callable passed to and_then is returning the wrong type.
// Wrong: parse_int returns optional<int>, not int
auto r = opt.and_then([](int n) { return n * 2; }); // ill-formed
// Correct: use transform for total functions
auto r = opt.transform([](int n) { return n * 2; });transform with a callable returning optional<T> β this compiles but produces optional<optional<T>>, almost certainly not the intended type.
std::optional<int> clamp_positive(int n); // returns optional
// Produces optional<optional<int>>, not optional<int>:
auto r = std::optional{-5}.transform(clamp_positive);
// Use and_then instead:
auto r = std::optional{-5}.and_then(clamp_positive);or_else callable returning T instead of optional<T> β also a constraint error. The fallback must return the same optional<T> specialization, not a bare T.
// Wrong:
auto r = opt.or_else([] { return 42; }); // ill-formed
// Correct:
auto r = opt.or_else([] { return std::optional{42}; });Chaining on temporaries with move-only types. The &&-qualified overloads move the contained value into the callable, so and_then on an rvalue optional correctly propagates ownership. If you call and_then on an lvalue optional holding a unique_ptr, the callable receives an lvalue reference β not a move β which is correct but can surprise readers expecting ownership transfer.
See Also
std::optionalβ the base type introduced in C++17std::expectedmonadic operations β C++23 equivalent with typed errors; shares the sameand_then,transform,or_elseinterface plustransform_error- Error handling strategies β comparison of
optional,expected, exceptions, and error codes