std::expected — Error-as-Value Without Exceptions
C++23 adds std::expected<T, E> — a vocabulary type that holds either a success value of type T or an error value of type E, never neither. Unlike exceptions it is always visible in the function signature, unlike raw error codes it carries structured information, and unlike std::optional it tells you why an operation failed. The result is error handling that reads like normal code — including a family of monadic operations that let you chain fallible steps without nested if blocks.
The problem with existing error-handling tools
Every C++ error-handling strategy has a well-known weak spot. Return codes — raw integers or enums — are immediately visible in the type but easy to ignore, carry little context, and pollute the normal flow with guard clauses. std::optional<T> handles the "absent value" case cleanly, but an empty optional gives no reason for the failure: you know the operation didn't produce a result, not why it didn't. Exceptions keep error handling visually separate, but they are invisible in the type signature and carry overhead whether or not they are thrown.
Consider a function that parses an integer from a string. The full int range is valid output, so there is no sentinel value left for "failure." An optional<int> could signal failure, but callers would have no idea whether the input was empty, malformed, or out of range. std::expected<int, std::string> stores the integer on success and the error message on failure — and the compiler won't let you forget to check which one you have.
// optional: caller knows nothing about the failure
std::optional<int> parse_v1(const std::string& s);
// expected: caller gets a structured reason
std::expected<int, std::string> parse_v2(const std::string& s);
// Caller can't access the int without going through the expected
auto r = parse_v2("abc");
// r.value() -- throws std::bad_expected_access if error
// *r -- UB if error; must check first
// r.error() -- the string "invalid stoi argument"Template parameters and construction
std::expected<T, E> takes two template parameters: T is the success type and E is the error type. An expected object is never empty — default-construction produces a value-holding object with a default-constructed T. This contrasts with optional, where default-construction gives an empty (absent) object.
Constructing a success value is implicit — you can just return a T from a function whose return type is expected<T, E>. Constructing an error requires wrapping it in std::unexpected<E> to disambiguate from the success case (particularly important when T and E are the same type). The error type can be as simple as a string or as rich as a custom struct; when multiple disjoint error categories are possible, std::variant<Err1, Err2> works as E.
#include <expected>
#include <string>
std::expected<int, std::string> parseInteger(const std::string& str)
{
try {
return std::stoi(str); // implicit: constructs the T (success)
} catch (const std::invalid_argument& e) {
return std::unexpected{ e.what() }; // explicit: constructs the E (error)
} catch (const std::out_of_range& e) {
return std::unexpected{ e.what() };
}
}
// Default-constructed expected holds a default T — not an error
std::expected<int, std::string> e; // holds int{} = 0, not an error
// Multiple error categories: E is a variant
enum class ParseErr { empty, malformed, overflow };
enum class IOErr { not_found, permission_denied };
std::expected<std::string, std::variant<ParseErr, IOErr>> complex_op();Accessing the value or the error
Before accessing either side, you must determine which one is present. has_value() and operator bool both return true when the object holds a success value. From there, the API deliberately mirrors optionalfor the value side and adds error() for the error side. The safe accessor value() throws std::bad_expected_access<E> if the object holds an error; operator* and operator-> are unchecked (undefined behavior on error), matching the optional design to signal "I already checked." Use value_or() when a default fallback is acceptable instead of error propagation.
auto result1 = parseInteger("123456789");
// Checking
if (result1.has_value()) { /* ... */ } // true if holds int
if (result1) { /* ... */ } // same thing via operator bool
// Safe access — throws bad_expected_access<string> if error
std::println("result1 = {}", result1.value());
// Unchecked access — UB if error; use only after has_value() check
std::println("result1 = {}", *result1);
// Fallback if error
std::println("result1 = {}", result1.value_or(0));
// Error access
auto result2 = parseInteger("12345678901234567"); // out of range
if (!result2) {
std::println("error: {}", result2.error());
// output: error: stoi argument out of range
}
auto result3 = parseInteger("abc");
if (!result3) {
std::println("error: {}", result3.error());
// output: error: invalid stoi argument
}| Member | Returns | Behaviour if wrong side |
|---|---|---|
| has_value() / operator bool | bool | — |
| value() | T& | throws bad_expected_access<E> |
| operator* / operator-> | T& / T* | undefined behaviour |
| error() | E& | undefined behaviour |
| value_or(default) | T | returns default (copies E is never accessed) |
Monadic operations — chaining without nesting
Repeated if (!result) guards make fallible pipelines verbose. The four monadic operations let you compose steps inline: if the object holds an error, the operation short-circuits and passes the error through; if it holds a value, the operation applies your function to it. This pattern — sometimes called "railway-oriented programming" — keeps the success path linear and the error path implicit until you decide to handle it.
The operations split neatly by which side they act on and what the callback must return. transform and transform_error accept callbacks returning a plain value (the library wraps it in an expected); and_then and or_else accept callbacks that return an expected themselves, enabling error-type changes and further fallible steps.
// transform: apply F to value, pass error through; F returns plain T2
auto doubled = parseInteger("21")
.transform([](int n) { return n * 2; });
// doubled holds 42 (int)
// and_then: apply F to value, pass error through; F returns expected<T2,E>
auto result = parseInteger("123456789")
.and_then([](int n) -> std::expected<int, std::string> {
if (n > 1'000'000) return std::unexpected{"value too large"};
return n;
});
// Chaining multiple steps — error short-circuits the whole chain
auto final = parseInteger(user_input)
.and_then(validate_range)
.transform(apply_offset)
.transform_error([](const std::string& e) {
return std::string("parse failed: ") + e; // enrich error message
});// or_else: apply F to error if present, pass value through; F returns expected
auto recovered = parseInteger("abc")
.or_else([](const std::string& err) -> std::expected<int, std::string> {
std::println("Warning: {}, using 0", err);
return 0; // recover with a default
});
// recovered holds 0 (int) — the error was handled
// transform_error: map E → E2 without touching T; useful for adapting error types
std::expected<int, std::string> raw = parseInteger("xyz");
std::expected<int, int> coded = raw.transform_error([](const std::string&) {
return -1; // convert string error to integer error code
});| Operation | Acts on | Callback must return | If other side present |
|---|---|---|---|
| transform(F) | value (T) | T2 (plain) | passes error through |
| and_then(F) | value (T) | expected<T2,E> | passes error through |
| or_else(F) | error (E) | expected<T,E2> | passes value through |
| transform_error(F) | error (E) | E2 (plain) | passes value through |
Multiple error types with std::variant
When an operation can fail in structurally different ways — say, a network fetch that can fail with either a parse error or a transport error — the error type E can be a std::variant. Callers use std::visit or std::holds_alternative to dispatch on the concrete error. This avoids inventing a single error enum that awkwardly lumps together unrelated failure modes.
struct NetworkError { int code; std::string message; };
struct ParseError { std::size_t offset; std::string what; };
using FetchError = std::variant<NetworkError, ParseError>;
std::expected<std::string, FetchError> fetch_and_parse(const std::string& url);
auto r = fetch_and_parse("https://example.com/data.json");
if (!r) {
std::visit([](auto&& err) {
using T = std::decay_t<decltype(err)>;
if constexpr (std::is_same_v<T, NetworkError>)
std::println("Network error {}: {}", err.code, err.message);
else
std::println("Parse error at offset {}: {}", err.offset, err.what);
}, r.error());
}When to use expected vs exceptions vs optional
The three mechanisms are not competing — they address different failure semantics. Use expected when the failure is a normal, anticipated outcome of the operation (a parse function will regularly receive bad input) and you want to communicate the exact reason. Use optional when absence is normal and the reason is obvious from context (e.g., map::findreturning empty when a key is missing). Use exceptions for truly exceptional conditions — programmer errors, invariant violations, or situations where propagating through many call frames with clean separation is the priority.
| Exception | Error return code | std::expected | |
|---|---|---|---|
| Visibility in signature | Hidden — requires reading docs | Immediately visible but easy to ignore | Immediately visible; compiler enforces checking |
| Error detail | Can hold rich structured info | Often just an integer | Can hold rich structured info |
| Code style | Cleanly separates normal flow from error flow | Error handling interleaved with normal flow | Monadic ops keep success path linear; error handled at boundary |
| Performance | Zero-cost when not thrown; expensive when thrown | Zero overhead | Zero overhead (value semantics) |
| Best for | Truly exceptional / programmer errors | Performance-critical C APIs or embedded | Expected failures with a reason (parsing, IO, validation) |
Rules to remember
std::expectedis never empty — default-construction holds a value-initialisedT.- To return an error, wrap it in
std::unexpected{value}— returning a bareEwould construct the success side ifTandEare convertible. operator*anderror()are unchecked; always gate them withif (r)orif (!r).- Monadic operations (
and_then,transform,or_else,transform_error) are const-qualified and return a new expected — they do not modify in place. - The error type
Emust be destructible and not an array type. It cannot bevoid(usestd::expected<T, std::monostate>if you need no error data).