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
#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));
}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.
#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
#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 barereturn e;would try to converteto aT. The wrapper makes the intent explicit and works even whenTandEare the same type. value()on an error throwsstd::bad_expected_access<E>, carrying the error;*randr.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:
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.
#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.
#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()));
}
}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.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?
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:
#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":
#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:
#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(oroptional)[[nodiscard]], so ignoring the result is a warning. Astd::expectedyou never look at is a silently swallowed error. (libstdc++ already marksstd::expecteditself[[nodiscard]], but that's an extension, andstd::optionalisn't.) - Make error types precise and cheap: an
enum class, or a small struct with a kind and a position — not astd::stringbuilt on the failure path of every call.
#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>: aTor nothing.expected<T, E>: aTor an errorE, created withstd::unexpected(e).*xwithout a check is UB;value()throws;value_orgives a default.- Chain with
transform(can't fail),and_then(can fail),or_else(recover),transform_error(rewrite the error). - C++26:
optionalis a 0-or-1 range, andoptional<T&>is a nullable reference. - Mark them
[[nodiscard]]; keep error types small and specific; save exceptions for failures the immediate caller can't handle.