Skip to content
C++
Library
since C++23
Basic

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++23

Three 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: calls f(*opt) if opt holds a value; f must return an optional. Returns empty if the input is empty.
  • transform β€” maps a total function over the value: calls f(*opt) and wraps the result in optional. f returns a plain value, not an optional.
  • or_else β€” provides a fallback when empty: returns *this unchanged if it holds a value, otherwise calls f() (which must return optional<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

cpp
// 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

cpp
#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 == nullopt

Transforming without introducing optionality

cpp
// 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

cpp
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 one

Real-world pipeline: request handling

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

cpp
// 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-empty

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

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

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

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

transform in Depth

transform is the functor map for optional: it applies a total (always-succeeding) function to the contained value and returns a new optional holding the result. If the input is empty, the callable is never invoked and an empty optional<U> is returned immediately.

The key rule: f must return a plain value, not an optional. The standard mandates that U = remove_cv_t<invoke_result_t<F, T&>> be a non-array object type other than in_place_t or nullopt_t.

cpp
#include <optional>
#include <string>
#include <cctype>

// Total function: always produces a result, never fails
std::string to_upper(std::string s) {
    for (char& c : s)
        c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
    return s;
}

std::optional<std::string> name = "alice";

// transform: f returns std::string, not optional<std::string>
auto upper = name.transform(to_upper);          // optional{"ALICE"}
auto empty = std::optional<std::string>{}
                 .transform(to_upper);           // nullopt β€” to_upper never called

// Chain multiple transforms: each changes the type
auto result = std::optional<int>{ 7 }
    .transform([](int n) { return n * n; })          // optional{49}
    .transform([](int n) { return std::to_string(n); }) // optional{"49"}
    .transform([](std::string s) { return "[" + s + "]"; }); // optional{"[49]"}

Contrast with and_then: if you accidentally pass a callable that returns optional<U> to transform, you get optional<optional<U>> β€” a legal type, but almost never what you want. The compiler will not warn; the type just nests.

cpp
std::optional<int> clamp_pos(int n) {
    return n > 0 ? std::optional{n} : std::nullopt;
}

// Wrong: produces optional<optional<int>>
auto bad  = std::optional{-1}.transform(clamp_pos); // optional<optional<int>>

// Correct: and_then flattens the result
auto good = std::optional{-1}.and_then(clamp_pos);  // nullopt (optional<int>)

or_else in Depth

or_else is the recovery branch. It takes a callable that accepts no arguments and must return optional<T> β€” the same specialisation as *this. The standard enforces this with a Mandates clause: is_same_v<remove_cvref_t<invoke_result_t<F>>, optional> must be true.

Semantics (expositional):

cpp
// const& overload
template<class F>
constexpr optional or_else(F&& f) const& {
    return has_value() ? *this : std::forward<F>(f)();
}

// && overload
template<class F>
constexpr optional or_else(F&& f) && {
    return has_value() ? std::move(*this) : std::forward<F>(f)();
}

or_else also appears in a pipeline to log or side-effect on failure while continuing the chain:

cpp
#include <optional>
#include <iostream>
#include <string>

std::optional<std::string> read_env(const char* key);
std::optional<std::string> read_file(const char* path);
std::optional<std::string> builtin_default();

// Cascading fallbacks β€” each or_else tried only if previous step was empty
auto setting = read_env("APP_TOKEN")
    .or_else([]() -> std::optional<std::string> {
        std::clog << "ENV miss; trying config file\n";
        return read_file("/etc/app/token");
    })
    .or_else([]() -> std::optional<std::string> {
        std::clog << "file miss; using built-in default\n";
        return builtin_default();
    });

The callable return-type rule catches a common mistake early:

cpp
// ill-formed: returns int, not optional<int>
auto bad = std::optional<int>{}.or_else([] { return 42; });

// correct
auto ok  = std::optional<int>{}.or_else([] { return std::optional{42}; });

Long Pipeline Example: Geocoding a User

The following pipeline combines all three operations in a realistic scenario: look up a user by ID, resolve their saved address, extract a city name, and format it for display.

cpp
#include <optional>
#include <string>
#include <format>

struct User    { int id; bool verified; std::string address_id; };
struct Address { std::string city; std::string country_code; };

// Failable lookups (return optional)
std::optional<User>    find_user(int user_id);
std::optional<Address> resolve_address(const std::string& addr_id);

// Total helpers (return plain values)
std::string format_city(const Address& addr) {
    return std::format("{} ({})", addr.city, addr.country_code);
}

// Full pipeline
std::optional<std::string> user_city_label(int user_id) {
    return find_user(user_id)
        // and_then: gate on a failable condition (unverified users have no address)
        .and_then([](const User& u) -> std::optional<User> {
            return u.verified ? std::optional{u} : std::nullopt;
        })
        // and_then: another failable lookup, type changes User -> Address
        .and_then([](const User& u) {
            return resolve_address(u.address_id);
        })
        // transform: total function, Address -> std::string
        .transform(format_city)
        // or_else: supply a sentinel value when anything above failed
        .or_else([] { return std::optional<std::string>{"(location unavailable)"}; });
}

Notice how each step communicates its semantics through the choice of operation:

  • and_then where failure is meaningful and the type may change.
  • transform where the conversion always succeeds.
  • or_else at the end to guarantee a non-empty result for callers that cannot handle absence.

Monadic Style vs. Manual if Chains

The same logic written with explicit has_value() checks requires four separate guard blocks and two intermediate named variables. The monadic version is also const-correct end-to-end, which the imperative version cannot be.

cpp
// Imperative style β€” C++17
std::optional<std::string> user_city_label_v1(int user_id) {
    auto user = find_user(user_id);
    if (!user || !user->verified)
        return "(location unavailable)";

    auto addr = resolve_address(user->address_id);
    if (!addr)
        return "(location unavailable)";

    return format_city(*addr);
}

// Monadic style β€” C++23
std::optional<std::string> user_city_label_v2(int user_id) {
    return find_user(user_id)
        .and_then([](const User& u) -> std::optional<User> {
            return u.verified ? std::optional{u} : std::nullopt;
        })
        .and_then([](const User& u) {
            return resolve_address(u.address_id);
        })
        .transform(format_city)
        .or_else([] { return std::optional<std::string>{"(location unavailable)"}; });
}

Practical trade-offs:

ConcernManual if chainMonadic chain
FamiliarityHigh β€” every C++ dev knows itLower β€” requires understanding of and_then vs transform
Refactor safetyForgetting a check silently compilesEmpty propagates automatically β€” no accidental *nullopt
const resultRequires pre-declaring std::optional<std::string> resultChain can be assigned to a const auto
Early-return side-effectsEasy β€” just return inside any blockNeeds a logging or_else in the middle of the chain
PerformanceZero overheadZero overhead β€” same branch structure, just inverted control
Debug readabilityEach step is a named statementAll steps in one expression; debugger steps may be coarser

Neither style is universally better. Prefer the monadic style when the chain has three or more steps with no side-effects between them. Fall back to if checks when steps need early logging, when failure messages differ per step, or when readability for the rest of your team matters more than expression-level elegance.

A practical middle ground: start every chain with and_then or transform, but intersperse or_else with logging lambdas at any step where the specific failure source is diagnostically important:

cpp
auto result = step_one()
    .or_else([] -> std::optional<T> {
        log_error("step_one failed");
        return std::nullopt;
    })
    .and_then(step_two)
    .or_else([] -> std::optional<T> {
        log_error("step_two failed");
        return std::nullopt;
    })
    .transform(finalize);

Combining and_then, transform, and or_else in a Multi-Step Parsing Pipeline

Monadic operations compose best when each step has a single, clear responsibility. A realistic parsing pipeline often involves several heterogeneous stages: tokenising raw input, validating a field, converting a type, and formatting output. Mapping those stages onto and_then / transform / or_else keeps the data flow linear while keeping each lambda narrow.

The example below parses a URL query parameter, converts it to an integer index, bounds-checks it against a table, and formats the matched entry. Each stage either terminates the chain early (returning nullopt) or passes a transformed value downstream.

cpp
// C++23
#include <optional>
#include <charconv>
#include <string_view>
#include <vector>
#include <format>

struct Product { int id; std::string name; double price; };

const std::vector<Product> kCatalog = {
    {1, "Widget",  9.99},
    {2, "Gadget", 24.99},
    {3, "Doohickey", 4.49},
};

// Step 1: parse raw query string β†’ int (failable β†’ and_then)
std::optional<int> parse_index(std::string_view sv) {
    int v{};
    auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), v);
    return (ec == std::errc{} && ptr == sv.data() + sv.size())
        ? std::optional{v} : std::nullopt;
}

// Step 2: bounds-check and look up catalog entry (failable β†’ and_then)
std::optional<Product> lookup_product(int idx) {
    if (idx < 0 || static_cast<std::size_t>(idx) >= kCatalog.size())
        return std::nullopt;
    return kCatalog[static_cast<std::size_t>(idx)];
}

// Step 3: format for display (total β†’ transform)
std::string format_product(const Product& p) {
    return std::format("#{}: {} β€” ${:.2f}", p.id, p.name, p.price);
}

// Full pipeline: query_param is a raw string like "1" or "abc"
std::optional<std::string> describe_product(std::string_view query_param) {
    return std::optional{query_param}
        .and_then(parse_index)       // "2"  β†’ optional{2}
        .and_then(lookup_product)    //  2   β†’ optional{Product{2,…}}
        .transform(format_product)   // Product β†’ optional{"#2: Gadget β€” $24.99"}
        .or_else([] {
            return std::optional<std::string>{"(product not found)"};
        });
}

The rule of thumb for choosing between the three operations is determined entirely by what the callable returns. Use and_then when the step can fail (callable returns optional<U>); use transform when the step always succeeds (callable returns a plain U); use or_else at any recovery point where an empty chain should be given a second chance or a sentinel value. Mixing them incorrectly β€” passing a total function to and_then, or a failable function to transform β€” either fails to compile or silently nests optional<optional<U>>.

optional Monadic Operations vs. std::expected

std::optional and std::expected share the same monadic interface in C++23, but serve different diagnostic needs. Both types provide and_then, transform, and or_else. std::expected<T, E> additionally provides transform_error, which maps a function over the error channel without touching the value channel. The choice between them is therefore not about composability β€” it is about whether callers need to know why an operation failed.

Use optional when:

  • Absence is self-explanatory in context (e.g., "no entry found", "field not present").
  • The failure is a normal control-flow outcome, not an error that callers need to distinguish.
  • You want the lightest possible type (optional carries only one bit of extra state).

Use expected when:

  • Different failure modes require different recovery strategies (e.g., ParseError::BadEncoding vs. ParseError::Overflow).
  • The error needs to propagate to a logging layer that produces structured diagnostics.
  • Multiple independent steps can fail for different reasons, and callers downstream need to distinguish them.
cpp
// C++23
#include <optional>
#include <expected>
#include <charconv>
#include <string_view>

enum class ParseError { InvalidInput, OutOfRange };

// optional: caller learns only whether parsing succeeded
std::optional<int> parse_int_simple(std::string_view sv) {
    int v{};
    auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), v);
    return ec == std::errc{} ? std::optional{v} : std::nullopt;
}

// expected: caller learns what went wrong
std::expected<int, ParseError> parse_int_rich(std::string_view sv) {
    int v{};
    auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), v);
    if (ec == std::errc::result_out_of_range)
        return std::unexpected{ParseError::OutOfRange};
    if (ec != std::errc{})
        return std::unexpected{ParseError::InvalidInput};
    return v;
}

// Monadic chains look identical to call sites:
auto r_opt = parse_int_simple("42")
    .transform([](int n) { return n * 2; });   // optional{84}

auto r_exp = parse_int_rich("42")
    .transform([](int n) { return n * 2; });   // expected{84}

// expected can also recover while preserving the error:
auto r_recovered = parse_int_rich("abc")
    .or_else([](ParseError e) -> std::expected<int, ParseError> {
        return e == ParseError::InvalidInput ? 0 : std::unexpected{e};
    });

If the error channel will always be ignored, prefer optional β€” it is simpler and communicates intent more clearly. Introduce expected only when the error value participates in business logic, drives different recovery paths, or needs to surface in user-facing diagnostics. Converting between the two is straightforward: opt.and_then(f) and exp.and_then(f) have the same call syntax, so migrating from optional to expected typically requires only changing the return types of the fallible functions and adding error-classification logic inside them.

See Also