Error Handling in C++ with std::expected
This article explores how std::expected, introduced in
C++23, provides a robust, type-safe mechanism for managing fallible
operations without the performance overhead or control-flow complexity
of C++ exceptions. You will learn the mechanics of the
std::expected<T, E> vocabulary type, how it replaces
error codes and exception handling, and how to chain operations using
its functional monadic interface.
The Mechanics of std::expected
Introduced in the C++23 standard library header
<expected>, std::expected<T, E> is
a vocabulary type that represents either a successful outcome containing
a value of type T, or an unexpected failure containing an
error of type E. Internally, it functions similarly to a
specialized std::variant<T, E>, allocating sufficient
storage to hold either T or E directly within
its footprint without dynamic memory allocation.
Because it does not rely on throwing exceptions,
std::expected avoids the runtime cost of stack unwinding
and table lookups, making it suitable for high-performance, real-time,
and embedded systems where exception handling is disabled via compiler
flags like -fno-exceptions.
Basic Usage and Syntax
When an operation succeeds, a function returns the value directly
(implicitly converted to T). When an operation fails, the
function returns the error wrapped in the helper type
std::unexpected.
#include <expected>
#include <string>
#include <string_view>
enum class ParseError {
EmptyInput,
InvalidCharacter
};
std::expected<int, ParseError> parse_positive_digit(std::string_view sv) {
if (sv.empty()) {
return std::unexpected(ParseError::EmptyInput);
}
if (sv[0] < '0' || sv[0] > '9') {
return std::unexpected(ParseError::InvalidCharacter);
}
return sv[0] - '0';
}The caller checks the outcome using explicit boolean conversion or
the has_value() member function:
auto result = parse_positive_digit("7");
if (result) {
int value = *result; // or result.value()
} else {
ParseError err = result.error();
}Accessing the value with operator* assumes success
without runtime checking. Calling .value() on an instance
containing an error throws a
std::bad_expected_access<E> exception, providing an
opt-in bridge back to standard exception mechanics when necessary.
Composing Operations with Monadic Interfaces
To avoid repetitive if (!result) boilerplate,
std::expected provides built-in monadic operations that
allow fallible functions to be chained declaratively:
and_then: Accepts a invocable that takesTand returns a newstd::expected<U, E>. If the original instance contains an error, the invocable is skipped, and the error propagates automatically.transform: Accepts a function that mapsTtoUfor non-fallible transformations, wrapping the result instd::expected<U, E>.or_else: Accepts a function that handles the error case, takingEand returning astd::expected<T, NewError>or recovering from the error.transform_error: Transforms the error typeEinto another error type without modifying successful values.
std::expected<double, ParseError> calculate_reciprocal(std::string_view input) {
return parse_positive_digit(input)
.and_then([](int val) -> std::expected<double, ParseError> {
if (val == 0) {
return std::unexpected(ParseError::InvalidCharacter);
}
return 1.0 / val;
});
}Advantages Over Alternative Techniques
Compared to traditional error-handling strategies,
std::expected offers distinct structural advantages:
- Compared to Exceptions: It creates deterministic, visible execution paths. Callers cannot accidentally ignore the fallibility of a function because the return type mandates unpacking the result.
- Compared to C-style Error Codes: It unifies the return channel. Functions no longer need output parameters passed by pointer or reference alongside an integer status code.
- Compared to
std::optional: Whilestd::optionalrepresents the potential absence of a value, it conveys no context regarding why an operation failed.std::expectedpreserves detailed diagnostic data through its error typeE.