std::expected — Error-as-Value (C++23)
Exceptions are excellent for truly exceptional situations, but not every failure is exceptional. Parsing user input, opening a file, or performing a mathematical operation on invalid data — these are expected outcomes of ordinary operations, and using exceptions for them blurs the line between normal control flow and catastrophe. std::expected<T, E>, introduced in C++23, solves this cleanly: it is a vocabulary type that always holds either a value of type T (the success case) or a value of type E (the error case). The caller is forced to check which it received — the error cannot be silently ignored the way exception specifications or error codes often are.
The problem: communicating failure without exceptions
Before std::expected, C++ programmers had two main options for reporting failure from a function: throw an exception, or return a sentinel value (like -1 or nullptr). Both have serious drawbacks. Exceptions require the caller to opt in to checking via try/catch, are invisible in function signatures, and have non-zero overhead even on the success path in some implementations. Sentinel values are type-unsafe: a function returning int can only signal -1 for error if that value is otherwise impossible, and there is no way to attach a rich error description to an integer.
std::expected makes the possibility of failure a first-class part of the function's type. A function declared to return std::expected<std::string, ErrorCode> communicates everything at a glance: on success you get a string; on failure you get an ErrorCode. The caller cannot ignore this without explicitly discarding the result. No dynamic memory allocation takes place — the stored value lives directly inside the expected object, just like a union.
// Before: error signals are invisible in the type system int parse_int(std::string_view s); // returns -1 on error? throws? who knows? // After: the type tells you everything #include <expected> std::expected<int, std::string> parse_int(std::string_view s); // • Returns int on success // • Returns std::string (error description) on failure // • Caller MUST handle both cases to use the value
Basic usage: returning and inspecting results
Inside the function, you return an unexpected value by wrapping it in std::unexpected<E>. This is a thin wrapper that exists solely to disambiguate construction — it tells the compiler you are constructing the error side, not the value side. A plain return fn; (where fn is the expected value type) constructs the success side. At the call site, an std::expected object converts to bool — true if it holds a value, false if it holds an error — making it natural to use in an if statement.
#include <expected>
#include <string>
std::expected<int, std::string> parse_int(std::string_view s) {
try {
return std::stoi(std::string(s)); // success: implicit conversion
} catch (...) {
return std::unexpected{std::string(s) + " is not a valid integer"};
}
}
int main() {
for (std::string_view input : {"42", "foo", "-7"}) {
auto result = parse_int(input);
if (result) { // bool conversion: true = has value
std::println("parsed: {}", result.value());
} else {
std::println("error: {}", result.error());
}
}
// parsed: 42
// error: foo is not a valid integer
// parsed: -7
}result.value() returns a reference to the contained value. If you call it when the result holds an error, it throws std::bad_expected_access<E>. This is the one place where std::expected can throw — it is a programming error to call value() without checking first. result.error() retrieves the error value, and is undefined behaviour if the result actually holds a value (check first with has_value() or the bool conversion).
auto r = parse_int("abc");
// Safe patterns:
if (r.has_value()) { use(r.value()); } // has_value() == operator bool
if (r) { use(r.value()); } // idiomatic
int v = r.value_or(0); // value or a default — never throws
// Less safe (be sure you checked):
int good = r.value(); // throws if r holds error
auto err = r.error(); // UB if r holds valueChoosing the error type
The error type E is a regular C++ type — you choose whatever best represents your domain. A std::string is fine for human-readable messages, but for programmatic handling, a scoped enum or a small struct carries richer, structured information that the caller can branch on without parsing text. Using an enum class also makes it impossible to accidentally mix error codes from different domains, since scoped enums do not implicitly convert.
enum class FileError { NotFound, PermissionDenied, DiskFull, IOError };
std::expected<std::string, FileError> read_file(const std::filesystem::path& p) {
std::ifstream f{p};
if (!f.is_open())
return std::unexpected{FileError::NotFound};
std::string content{std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>()};
if (f.fail())
return std::unexpected{FileError::IOError};
return content; // success
}
// Call site: structured switch makes every error case explicit
auto result = read_file("/etc/shadow");
if (!result) {
switch (result.error()) {
case FileError::NotFound: handle_missing(); break;
case FileError::PermissionDenied: handle_denied(); break;
case FileError::IOError: handle_io_error(); break;
case FileError::DiskFull: handle_full(); break;
}
}Member API at a glance
std::expected<T, E> has an interface deliberately modelled on std::optional— if you already know optional, most of this will feel familiar. The key difference is that instead of "no value" there is "an error value," and that error value carries information.
| Expression | Returns / Effect | Throws if wrong side? |
|---|---|---|
| if (exp) | true when holds a value | — |
| exp.has_value() | bool — same as operator bool | — |
| exp.value() | T& — the success value | bad_expected_access<E> |
| exp.value_or(d) | T — value, or d if error | never |
| exp.error() | E& — the error value | UB (undefined behaviour) |
| exp.emplace(args…) | Constructs value in-place | — |
| *exp | T& — like value() but no check | UB |
| exp->member | Access T member — no check | UB |
Monadic chaining: composing fallible operations
One of the most ergonomic features of std::expected is its support for monadic operations — methods that let you chain transformations without writing an explicit if (!result) return result; guard at every step. The idea is simple: if the result holds an error, the chained operation is skipped entirely and the error is propagated unchanged. You only write the happy-path logic; errors fall through automatically.
There are four monadic operations: and_then applies a function that itself returns an expected (used for chaining fallible steps); transform applies a function that returns a plain value (infallible mapping); or_else applies a function when there is an error (recovery); transform_error maps the error value through a function (error type conversion). These mirror the monadic operations added to std::optional in C++23.
std::expected<int, std::string> parse_int(std::string_view s);
std::expected<double, std::string> to_reciprocal(int n);
std::string stringify(double d);
// Without chaining — error guards at every step
auto r1 = parse_int(input);
if (!r1) return r1.error();
auto r2 = to_reciprocal(*r1);
if (!r2) return r2.error();
std::string s = stringify(*r2);
// With chaining — errors propagate automatically
std::string s = parse_int(input)
.and_then(to_reciprocal) // skipped if parse_int returned error
.transform(stringify) // skipped if to_reciprocal returned error
.value_or("error"); // safe extraction at the endand_then(f)
When triggered: result holds a value
calls f(value), which must return std::expected<U, E>; propagates error unchanged if no value
transform(f)
When triggered: result holds a value
calls f(value), wraps plain return in expected<U, E>; propagates error unchanged if no value
or_else(f)
When triggered: result holds an error
calls f(error), which must return std::expected<T, G>; passes value through unchanged
transform_error(f)
When triggered: result holds an error
calls f(error), wraps result in expected<T, G>; passes value through unchanged
std::expected vs std::optional
std::optional<T> is the right choice when absence is a meaningful state but there is nothing to explain — a search that found nothing, an optional configuration field, a value that may not yet exist. The empty state carries no information. std::expected<T, E> is the right choice when the absence of a value needs to be explained — the error is information. Use optional for "maybe," use expected for "either success or a specific reason for failure."
| Aspect | std::optional<T> | std::expected<T, E> |
|---|---|---|
| Empty state carries info | No (just absent) | Yes (typed error E) |
| Error type | n/a | Any type you choose |
| Header | <optional> | <expected> |
| Standard | C++17 | C++23 |
| Similarity | — | Interface mirrors optional |
| Monadic ops | C++23 and_then/transform/or_else | Same operations + transform_error |
| Best for | Optional fields, nullable return | Fallible operations with typed errors |
When to prefer std::expected over exceptions
The right choice between std::expected and exceptions depends on whether the failure is part of the function's normal contract. Parsing a string that might not be a number is a normal outcome — the caller routinely handles both cases. Opening a configuration file that might not exist is normal. By contrast, an out-of-memory condition or a detected-corrupted-state invariant violation is genuinely exceptional — the program may not be able to recover regardless.
Use std::expected when callers routinely handle both outcomes
Parsing, validation, I/O, protocol decoding — failures are expected and the caller needs the error value.
Use exceptions for truly exceptional, unrecoverable conditions
Out-of-memory, violated invariants, programming errors. Callers generally cannot do anything useful with these.
Use std::expected in performance-critical or noexcept contexts
Expected has zero cost on the success path and works in noexcept functions. Exceptions cannot.
Use std::expected when failure needs structured error information
An enum class error code lets callers branch precisely. An exception's message string requires parsing.
Avoid std::expected as a way to avoid learning exceptions
They are complementary, not competing. Exceptions remain the right tool for truly exceptional failures.
Compiler support and feature detection
std::expected requires C++23 and a sufficiently recent standard library. GCC 12+, Clang 16+, and MSVC 19.35+ all support it. You can guard against missing support using the feature-test macro, which is the standard mechanism for detecting library features independently of the compiler version:
#include <version> #if __cpp_lib_expected >= 202211L # include <expected> // use std::expected #else # error "std::expected not available — upgrade to C++23 or use a polyfill" #endif // Compilation flags: // GCC/Clang: g++ -std=c++23 main.cpp // MSVC: cl /std:c++latest main.cpp