CodeRigor

FREE LESSON

Errors as values: optional and expected

Return "maybe a value" and "a value or an error" as types, chain them without nested ifs, and know when an exception is the better tool.

Some functions can't always produce a result: a lookup that may miss, a parse that may fail. C++ gives you three ways to say so — a sentinel value (-1, nullptr, end()), an exception, or a type that carries the outcome. This lesson is about the third: std::optional<T> ("a T or nothing") and std::expected<T, E> ("a T or an error E"), and how to chain them so error handling doesn't drown the logic.

1. std::optional<T>: maybe a value

C++
#include <optional>
#include <print>
#include <string_view>

std::optional<int> digit_value(char c) {
    if (c >= '0' && c <= '9') return c - '0';
    return std::nullopt;                       // "no value"
}

int main() {
    auto a = digit_value('7');
    auto b = digit_value('x');
    std::println("{} {}", a.has_value(), b.has_value());   // true false
    if (a) std::println("a = {}", *a);                     // contextual bool, then *
    std::println("b or 0 = {}", b.value_or(0));
}
An optional stores its value inline
A T plus an engaged flag, padded to T's alignment: sizeof(std::optional<int>) is 8 with GCC. No heap allocation.
An optional stores its value inline
A T plus an engaged flag, padded to T's alignment: sizeof(std::optional<int>) is 8 with GCC. No heap allocation.

An optional<T> stores the T inside itself (no heap allocation): it's roughly a T plus a bool. Three ways to get at the value, with very different failure modes:

Access If empty
*opt, opt->member undefined behaviour (a hardened library aborts)
opt.value() throws std::bad_optional_access
opt.value_or(fallback) returns fallback

Use *opt right after you've checked; value_or when there's a sensible default; value() when emptiness is truly exceptional.

C++
#include <optional>
#include <print>

int main() {
    std::optional<int> none;
    try {
        std::println("{}", none.value());
    } catch (const std::bad_optional_access& e) {
        std::println("caught: {}", e.what());
    }
    // std::println("{}", *none);   // UB: never dereference without checking
}

optional is the right return type for "not found" or "not applicable" — cases where why there's no value doesn't matter. When the caller needs to know what went wrong, you want expected.

2. std::expected<T, E>: a value or an error

C++
#include <charconv>
#include <expected>
#include <print>
#include <string_view>

enum class ParseError { empty, not_a_number, out_of_range };

std::expected<int, ParseError> parse_int(std::string_view s) {
    if (s.empty()) return std::unexpected(ParseError::empty);
    int value{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec == std::errc::result_out_of_range) return std::unexpected(ParseError::out_of_range);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::unexpected(ParseError::not_a_number);
    return value;                               // implicitly a success
}

int main() {
    for (std::string_view s : {"42", "", "4x", "99999999999"}) {
        auto r = parse_int(s);
        if (r) std::println("'{}' -> {}", s, *r);
        else   std::println("'{}' -> error {}", s, static_cast<int>(r.error()));
    }
}

The shape is the same as optional — has_value(), *, value(), value_or() — plus error() to read the error. Two details matter:

  • Errors are spelled std::unexpected(e). A bare return e; would try to convert e to a T. The wrapper makes the intent explicit and works even when T and E are the same type.
  • value() on an error throws std::bad_expected_access<E>, carrying the error; *r and r.error() on the wrong state are undefined behaviour.

3. Chaining without nested ifs

Real code does several fallible steps in a row. Checking each by hand produces the "arrow" shape:

C++
auto cfg = parse_config(text);
if (!cfg) return std::unexpected(cfg.error());
auto port_text = lookup(*cfg, "port");
if (!port_text) return std::unexpected(Error{missing_key});
auto port = parse_int(*port_text);
if (!port) return std::unexpected(port.error());
return *port;

C++23 gives both types monadic operations that do the checking for you:

Member Calls f when… f returns Result
transform(f) there's a value a plain value U optional<U> / expected<U, E>
and_then(f) there's a value an optional / expected (may fail) that, flattened
or_else(f) it's empty / an error an optional / expected (recovery) that
transform_error(f) it's an error (expected only) a new error G expected<T, G>

The rule of thumb: transform for steps that can't fail, and_then for steps that can. If an earlier step failed, later steps are skipped and the error flows through untouched.

C++
#include <expected>
#include <print>
#include <string>

enum class Err { negative, too_big };

std::expected<int, Err> check_non_negative(int x) {
    if (x < 0) return std::unexpected(Err::negative);
    return x;
}
std::expected<int, Err> check_small(int x) {
    if (x > 100) return std::unexpected(Err::too_big);
    return x;
}

std::expected<std::string, Err> describe(int input) {
    return check_non_negative(input)
        .and_then(check_small)                                   // may fail
        .transform([](int x) { return x * 2; })                  // can't fail
        .transform([](int x) { return "doubled: " + std::to_string(x); });
}

int main() {
    for (int x : {21, -3, 500}) {
        auto r = describe(x);
        std::println("{}", r ? *r : "error " + std::to_string(static_cast<int>(r.error())));
    }
}

Worked example: a value from a config string

Given "host=example;port=8080", get the port as an int, or an error that says what was wrong. Each step is a small function; the pipeline reads top to bottom.

C++
#include <charconv>
#include <expected>
#include <optional>
#include <print>
#include <string_view>

enum class Error { missing_key, bad_number };

// Value for `key` in "k=v;k=v", or nullopt. Views point into `text`.
std::optional<std::string_view> find_value(std::string_view text, std::string_view key) {
    while (!text.empty()) {
        auto semi = text.find(';');
        auto entry = text.substr(0, semi);
        if (auto eq = entry.find('='); eq != entry.npos && entry.substr(0, eq) == key)
            return entry.substr(eq + 1);
        if (semi == text.npos) break;
        text.remove_prefix(semi + 1);
    }
    return std::nullopt;
}

std::expected<int, Error> to_int(std::string_view s) {
    int v{};
    auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || p != s.data() + s.size() || s.empty()) return std::unexpected(Error::bad_number);
    return v;
}

std::expected<int, Error> port(std::string_view config) {
    return find_value(config, "port")                    // optional<string_view>
        .transform(to_int)                               // optional<expected<int, Error>>
        .value_or(std::unexpected(Error::missing_key));  // expected<int, Error>
}

int main() {
    for (std::string_view c : {"host=example;port=8080", "host=example", "port=80x"}) {
        auto p = port(c);
        if (p) std::println("{} -> {}", c, *p);
        else   std::println("{} -> error {}", c, static_cast<int>(p.error()));
    }
}
port(config): one success track, errors bypass the rest
transform only runs on a value; an empty optional skips it and value_or turns it into missing_key. A failed parse is already an expected error and passes straight through.
port(config): one success track, errors bypass the rest
transform only runs on a value; an empty optional skips it and value_or turns it into missing_key. A failed parse is already an expected error and passes straight through.
Three inputs through the pipeline
Columns: the result after find_value, after .transform(to_int), after .value_or(…). Once a step yields "nothing" or an error, the later steps pass it along without running.
Three inputs through the pipeline
Columns: the result after find_value, after .transform(to_int), after .value_or(…). Once a step yields "nothing" or an error, the later steps pass it along without running.

Notice the seam between the two types: find_value returns an optional (not found has no further detail), but the pipeline must produce an expected. transform(to_int) gives optional<expected<int, Error>> — the parse result, if there was anything to parse — and value_or(std::unexpected(Error::missing_key)) turns "absent" into an error at exactly one place. There is no built-in optional → expected conversion; this transform + value_or idiom is the usual bridge.

Faded example — your turn

Write timeout_ms(config): look up "timeout", parse it, and reject values that are not positive with a new error Error::out_of_range. Which steps are transform and which and_then?

C++
std::expected<int, Error> timeout_ms(std::string_view config) {
    return find_value(config, "timeout")
        .transform(/* ??? */)
        .value_or(std::unexpected(Error::missing_key))
        .and_then(/* ??? */);
}
Answer

.transform(to_int) (parsing can fail, but we're inside an optional, so the whole expected becomes the optional's value), then after value_or we have an expected<int, Error>, and the range check can fail, so .and_then([](int v) -> std::expected<int, Error> { if (v <= 0) return std::unexpected(Error::out_of_range); return v; }).

4. C++26: optional as a range, and optional<T&>

Two additions in C++26 (both supported by GCC 16):

An optional is a range of zero or one elements, so it works with range-for and with views — handy for "do this if present" without an if:

C++
#include <optional>
#include <print>

int main() {
    std::optional<int> some = 7, none;
    for (int x : some) std::println("some: {}", x);   // runs once
    for (int x : none) std::println("none: {}", x);   // runs zero times
}

std::optional<T&> is an optional reference: a nullable, non-owning handle to an existing object — like a pointer, but with optional's interface. It's the natural return type for "find the element and let me modify it":

C++
#include <optional>
#include <print>
#include <string>
#include <vector>

struct Item {
    std::string name;
    int stock;
};

std::optional<Item&> find(std::vector<Item>& items, std::string_view name) {
    for (auto& it : items)
        if (it.name == name) return it;      // refers to the element, no copy
    return std::nullopt;
}

int main() {
    std::vector<Item> items{{"bolt", 10}, {"nut", 3}};
    if (auto it = find(items, "nut")) it->stock += 5;
    std::println("{}", items[1].stock);      // 8
}

The usual reference rule applies: the referenced object must outlive the optional<T&>. The library helps where it can: an optional<T&> refuses to bind to a temporary — that would dangle immediately — so even a loop over copies is rejected:

C++
#include <optional>
#include <string>
#include <vector>

std::optional<const std::string&> first_long(const std::vector<std::string>& v) {
    for (std::string s : v)              // a copy per iteration...
        if (s.size() > 3) return s;      // error: would bind to a dying object
    return std::nullopt;
}

It can't catch everything, though: returning a reference into a by-value parameter compiles and dangles once the call's full-expression ends.

5. Which tool when?

Situation Prefer
"Not found" / "not applicable"; the reason is obvious std::optional<T> (or optional<T&> for a reference)
The caller must handle why it failed, often right away (parsing, validation, I/O results) std::expected<T, E>
Failure is rare, the caller can't do anything locally, it should unwind several frames (out of memory, broken invariant, constructor failure) an exception
Hot path where even a branch on a status is too much, or a C interface error codes / sentinels

Two habits make value-based errors work:

  • Mark functions returning expected (or optional) [[nodiscard]], so ignoring the result is a warning. A std::expected you never look at is a silently swallowed error. (libstdc++ already marks std::expected itself [[nodiscard]], but that's an extension, and std::optional isn't.)
  • Make error types precise and cheap: an enum class, or a small struct with a kind and a position — not a std::string built on the failure path of every call.
C++
#include <optional>

[[nodiscard]] std::optional<int> may_fail();

int main() {
    // Not a hard error by default; this snippet promotes the warning:
#pragma GCC diagnostic error "-Wunused-result"
    may_fail();      // error: ignoring return value declared nodiscard
}

Summary

  • optional<T>: a T or nothing. expected<T, E>: a T or an error E, created with std::unexpected(e).
  • *x without a check is UB; value() throws; value_or gives a default.
  • Chain with transform (can't fail), and_then (can fail), or_else (recover), transform_error (rewrite the error).
  • C++26: optional is a 0-or-1 range, and optional<T&> is a nullable reference.
  • Mark them [[nodiscard]]; keep error types small and specific; save exceptions for failures the immediate caller can't handle.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Exceptions vs optional vs expected and the C++23 monadic operations — when to use which.

The design argument behind expected<T, E> from the person who popularised it.

History and trade-offs of C++ error handling: error codes, exceptions and value-based errors.

Common misuses of optional, variant and expected, and the fixes.

std::optional basics: an empty-or-value return type instead of sentinels or out-parameters.