Skip to content

std::expected<T, E>: Type-Safe Error Propagation ​

In the previous article we discussed std::optional for error handling and pointed out its limitation — it cannot carry error information. When you need to know "why it failed", optional falls short. std::expected<T, E>, introduced in C++23, exists precisely to fill this gap: it tells you both "is there a value" and "what is the reason there is no value".

If you have touched Rust, expected will feel familiar — its design is cut from the same cloth as Rust's Result<T, E>: it holds a value T on success and an error E on failure. The difference is that C++ has neither the compiler-enforced must_use check nor the ? operator, so we have to lean on monadic operations and coding discipline to compensate.

One caveat up front: std::expected is a C++23 feature. If you are currently on C++17 or C++20, this article provides a usable simplified implementation; and since expected has no RTTI dependency, it works just fine in embedded settings as well.


The Core Semantics of expected ​

std::expected<T, E> is a template class that holds either a success value of type T or an error object of type E. Its interface design borrows from optional — you can check for success with operator bool() or has_value(), fetch the value with value(), and fetch the error with error():

Expand codeCollapse39 lines
C++
#include <expected>
#include <string>
#include <iostream>

enum class ParseError {
    kEmptyInput,
    kInvalidCharacter,
    kOutOfRange,
};

std::expected<int, ParseError> parse_int(const std::string& s) {
    if (s.empty()) {
        return std::unexpected(ParseError::kEmptyInput);
    }

    try {
        std::size_t pos = 0;
        int value = std::stoi(s, &pos);
        if (pos != s.size()) {
            return std::unexpected(ParseError::kInvalidCharacter);
        }
        return value;
    } catch (...) {
        return std::unexpected(ParseError::kOutOfRange);
    }
}

int main() {
    auto r1 = parse_int("42");
    if (r1) {
        std::cout << "Value: " << r1.value() << "\n";  // 42
    }

    auto r2 = parse_int("42abc");
    if (!r2) {
        std::cout << "Error: " << static_cast<int>(r2.error()) << "\n";
        // Prints Error: 1 (kInvalidCharacter)
    }
}

std::unexpected(E) is a helper template dedicated to constructing the error branch of an expected. It plays the same role for expected that std::nullopt plays for optional — an explicit way to say "this is an error".

How the value and the error are packed into one return type — and how they are taken back out — is captured in an animation. You can play it, pause it, or step through it frame by frame, walking the value channel and the error channel one at a time:

0.0s / 56.6s
STEP 01开场

Construction and Access ​

expected offers a fairly rich set of construction options. The most basic ones: construct directly from a value to represent success, and construct from std::unexpected to represent failure:

C++
// Construct from a success value
std::expected<int, std::string> success = 42;

// Construct from an error
std::expected<int, std::string> failure =
    std::unexpected("something went wrong");

// Construct in place
std::expected<std::string, int> in_place_success{
    std::in_place, "hello"};

For access, expected provides an interface similar to optional's, but adds one crucial member — error():

C++
std::expected<int, std::string> result = 42;

// Check
result.has_value();      // true
static_cast<bool>(result);  // true

// Access the value
result.value();          // 42; throws std::bad_expected_access if empty
*result;                 // 42; no check, undefined behavior if empty (like optional's operator*)
result->some_member;     // if T is a struct

// Access the error (only call when !has_value())
std::expected<int, std::string> err =
    std::unexpected("oops");
err.error();             // "oops"

// Safe default value
result.value_or(0);      // returns the value if present, otherwise 0

The difference between value() and operator* is this: the former throws the std::bad_expected_access<E> exception when the expected is in the error state, while the latter is undefined behavior. So use * on paths where you are certain a value is there, and use value() — or an explicit has_value() check first — on paths where you are not so sure.


Monadic Operations ​

This is the most powerful part of expected. C++23's expected natively supports four monadic operations, letting you organize multiple fallible operations as chained calls instead of nesting if/else layer upon layer.

and_then: Chaining Fallible Operations ​

and_then takes a function f; f receives the value inside the expected and returns a new expected. If the current expected is in the error state, f is not called — the error passes straight through to the end of the chain:

Expand codeCollapse31 lines
C++
#include <expected>
#include <string>
#include <iostream>

std::expected<int, std::string> validate_positive(int value) {
    if (value > 0) return value;
    return std::unexpected("Value must be positive");
}

std::expected<double, std::string> safe_divide(int num, int denom) {
    if (denom == 0) {
        return std::unexpected("Division by zero");
    }
    return static_cast<double>(num) / denom;
}

int main() {
    std::string input = "42";

    auto result = parse_int(input)
        .and_then(validate_positive)
        .and_then([](int v) {
            return safe_divide(v, 2);
        });

    if (result) {
        std::cout << "Result: " << *result << "\n";  // 21.0
    } else {
        std::cout << "Error: " << result.error() << "\n";
    }
}

If parse_int returns an error, neither validate_positive nor the lambda executes — the error shows up directly in result.error(). That is what "errors propagate automatically" means.

transform: Transforming the Value ​

The difference between transform and and_then is that the function you pass in returns a plain value rather than an expected. transform wraps that return value into a new expected for you:

C++
auto result = parse_int("42")
    .transform([](int v) { return v * 2; })
    .transform([](int v) { return std::to_string(v); });
// The type of result is std::expected<std::string, ParseError>

Here the first transform turns an int into an int (doubled), and the second turns the int into a std::string. If any step along the way fails, the subsequent transform calls do not run.

transform suits transformations that cannot fail themselves. If an operation may fail, use and_then; if it always succeeds, use transform.

or_else: Handling Errors ​

or_else invokes the function you pass in when the expected is in the error state. It is typically used for error recovery, logging, or error enrichment:

C++
std::expected<int, std::string> try_cache(int key) {
    return std::unexpected("cache miss for " + std::to_string(key));
}

std::expected<int, std::string> try_database(int key) {
    return key * 100;  // Simulate fetching from the database
}

int main() {
    auto result = try_cache(42)
        .or_else([](const std::string& err) {
            std::cerr << "Cache failed: " << err << ", trying DB\n";
            return try_database(42);
        });

    // result holds 4200
}

The function passed to or_else must return an expected of the same type. This means you can perform error recovery inside or_else — if the fallback operation succeeds, the rest of the chain continues down the success path.

transform_error: Transforming the Error Type ​

transform_error lets you transform the error object while it propagates, without affecting the success path. This is extremely useful for cross-layer error propagation — a lower layer may use one error type while an upper layer needs another:

C++
struct AppError {
    int code;
    std::string message;
    std::string context;  // Extra context information
};

auto result = parse_int("abc")
    .transform_error([](ParseError e) -> AppError {
        return AppError{static_cast<int>(e),
                        "Parse error",
                        "in config file line 1"};
    });
// The type of result is std::expected<int, AppError>

A Complete Chained Example ​

Combine the four operations and you get a complete error-handling pipeline:

Expand codeCollapse65 lines
C++
#include <expected>
#include <string>
#include <iostream>
#include <charconv>
#include <system_error>

enum class ConfigError {
    kFileNotFound,
    kParseError,
    kValidationError,
};

struct ServerConfig {
    std::string host;
    int port;
};

std::expected<std::string, ConfigError> read_file(
    const std::string& path) {
    // Simplified: assume it always succeeds
    return "host=192.168.1.1\nport=8080\n";
}

std::expected<ServerConfig, ConfigError> parse_config(
    const std::string& content) {
    ServerConfig cfg;
    cfg.host = "localhost";
    cfg.port = 8080;
    // Simplified: actually parse the content
    return cfg;
}

std::expected<ServerConfig, ConfigError> validate_config(
    ServerConfig cfg) {
    if (cfg.port < 1 || cfg.port > 65535) {
        return std::unexpected(ConfigError::kValidationError);
    }
    return cfg;
}

int main() {
    auto result = read_file("server.cfg")
        .and_then(parse_config)
        .and_then(validate_config)
        .transform([](const ServerConfig& cfg) -> std::string {
            return cfg.host + ":" + std::to_string(cfg.port);
        })
        .transform_error([](ConfigError e) -> std::string {
            switch (e) {
                case ConfigError::kFileNotFound:
                    return "Config file not found";
                case ConfigError::kParseError:
                    return "Config parse error";
                case ConfigError::kValidationError:
                    return "Config validation failed";
            }
            return "Unknown error";
        });

    if (result) {
        std::cout << "Server: " << *result << "\n";
    } else {
        std::cerr << "Failed: " << result.error() << "\n";
    }
}

This chain reads very clearly: read the file -> parse the config -> validate the config -> convert to a connection string. If any step fails, the subsequent steps are skipped automatically, and the error message is handled uniformly at the end of the chain.


expected vs Exceptions vs optional ​

We have put together a comparison table to help you choose in real-world scenarios:

ScenarioRecommended approachWhy
Lookup / caching, failure carries no reasonoptionalConcise; no error information needed
Parsing / I/O, you need to know why it failedexpectedCarries error information
Multi-step operation chains that need error propagationexpectedMonadic operations support chaining
Unrecoverable, serious errorsexceptionsForces a break; RAII cleans up automatically
Constructor failureexceptionsConstructors have no return value
Embedded (no exception support)expected or an enumNo RTTI dependency

A practical rule of thumb: if the caller needs to do different things depending on the error type (retry, degrade, report), use expected; if it only needs to know "success or failure", use optional; and if it is a serious error at the program-logic level (impossible to recover from), use exceptions.


A Simplified Implementation for C++17 ​

If your project is still on C++17, don't worry — you can implement a simplified yet fully functional expected. The implementation below covers the core functionality and can be dropped straight into a project:

Expand codeCollapse96 lines
C++
#include <utility>
#include <type_traits>
#include <stdexcept>

/// Helper type: used to construct the error branch
template <typename E>
struct unexpected {
    E value;
    constexpr explicit unexpected(E v) : value(std::move(v)) {}
};

/// A simplified expected<T, E>
template <typename T, typename E>
class expected {
    bool has_value_;
    union {
        T val_;
        E err_;
    } storage_;

public:
    // Construct from a success value
    expected(const T& v) : has_value_(true) {
        new(&storage_.val_) T(v);
    }

    expected(T&& v) : has_value_(true) {
        new(&storage_.val_) T(std::move(v));
    }

    // Construct from an error
    expected(unexpected<E> u) : has_value_(false) {
        new(&storage_.err_) E(std::move(u.value));
    }

    // Destructor
    ~expected() {
        if (has_value_) storage_.val_.~T();
        else storage_.err_.~E();
    }

    constexpr bool has_value() const noexcept { return has_value_; }
    constexpr explicit operator bool() const noexcept {
        return has_value_;
    }

    T& value() {
        if (!has_value_)
            throw std::runtime_error("bad expected access");
        return storage_.val_;
    }

    const T& value() const {
        if (!has_value_)
            throw std::runtime_error("bad expected access");
        return storage_.val_;
    }

    const E& error() const {
        if (has_value_)
            throw std::runtime_error("no error present");
        return storage_.err_;
    }

    T& operator*() { return storage_.val_; }
    T* operator->() { return &storage_.val_; }

    T value_or(T default_val) const {
        return has_value_ ? storage_.val_ : default_val;
    }

    /// and_then: chain an operation that returns an expected
    template <typename F>
    auto and_then(F&& f) -> decltype(f(std::declval<T>())) {
        using ResultType = decltype(f(std::declval<T>()));
        if (has_value_) return f(storage_.val_);
        return ResultType(unexpected<E>{storage_.err_});
    }

    /// transform: transform the value
    template <typename F>
    auto transform(F&& f)
        -> expected<decltype(f(std::declval<T>())), E> {
        using U = decltype(f(std::declval<T>()));
        if (has_value_)
            return expected<U, E>(f(storage_.val_));
        return expected<U, E>(unexpected<E>{storage_.err_});
    }

    /// or_else: handle the error
    template <typename F>
    expected or_else(F&& f) {
        if (has_value_) return *this;
        return f(storage_.err_);
    }
};

This implementation omits some details (fine-grained control over copy/move semantics, constexpr support, and so on), but its core semantics are completely correct, and it can be used for error handling in production environments.


A Practical Example: A Multi-Layer Parsing Chain ​

Let's look at an example closer to real-world development — parsing a network address out of a string, involving multiple stages of validation and conversion:

Expand codeCollapse80 lines
C++
#include <string>
#include <string_view>
#include <expected>
#include <iostream>
#include <charconv>

struct AddressError {
    enum Code {
        kEmptyInput,
        kMissingPort,
        kInvalidHost,
        kInvalidPort,
        kPortOutOfRange,
    } code;
    std::string detail;
};

struct NetworkAddress {
    std::string host;
    int port;
};

std::expected<std::string, AddressError> validate_input(
    std::string_view input) {
    if (input.empty()) {
        return std::unexpected(AddressError{
            AddressError::kEmptyInput, "Input is empty"});
    }
    return std::string(input);
}

std::expected<NetworkAddress, AddressError> split_address(
    std::string input) {
    auto colon = input.rfind(':');
    if (colon == std::string::npos) {
        return std::unexpected(AddressError{
            AddressError::kMissingPort,
            "No port specified: " + input});
    }

    NetworkAddress addr;
    addr.host = input.substr(0, colon);
    if (addr.host.empty()) {
        return std::unexpected(AddressError{
            AddressError::kInvalidHost, "Host is empty"});
    }

    auto port_str = input.substr(colon + 1);
    int port = 0;
    auto [ptr, ec] = std::from_chars(
        port_str.data(), port_str.data() + port_str.size(), port);
    if (ec != std::errc{} || ptr != port_str.data() + port_str.size()) {
        return std::unexpected(AddressError{
            AddressError::kInvalidPort,
            "Port is not a number: " + std::string(port_str)});
    }
    if (port < 1 || port > 65535) {
        return std::unexpected(AddressError{
            AddressError::kPortOutOfRange,
            "Port out of range: " + std::to_string(port)});
    }
    addr.port = port;
    return addr;
}

int main() {
    auto result = validate_input("192.168.1.1:8080")
        .and_then(split_address)
        .transform([](const NetworkAddress& a) -> std::string {
            return a.host + ":" + std::to_string(a.port);
        })
        .or_else([](const AddressError& e) -> std::expected<std::string, AddressError> {
            std::cerr << "Error: " << e.detail << "\n";
            return std::unexpected(e);
        });

    if (result) {
        std::cout << "Address: " << *result << "\n";
    }
}

This example demonstrates the advantage of expected in multi-layer operations: every step returns an expected, any step that fails propagates automatically, and everything is handled uniformly at the end of the chain. The error information carries plenty of context — the detail field tells you exactly what went wrong.


References ​

pdf-latest-4-g85128cc · 85128cc · 2026-10-05