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 takes T and returns a new std::expected<U, E>. If the original instance contains an error, the invocable is skipped, and the error propagates automatically.
  • transform: Accepts a function that maps T to U for non-fallible transformations, wrapping the result in std::expected<U, E>.
  • or_else: Accepts a function that handles the error case, taking E and returning a std::expected<T, NewError> or recovering from the error.
  • transform_error: Transforms the error type E into 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: While std::optional represents the potential absence of a value, it conveys no context regarding why an operation failed. std::expected preserves detailed diagnostic data through its error type E.