std::expected (C++23)

Type-safe error handling without exceptions

The Error Handling Dilemma

Exceptions

  • ✓ Clean error propagation
  • ✓ Type-safe
  • ✗ Runtime overhead
  • ✗ Hidden control flow
  • ✗ Not deterministic

Error Codes

  • ✓ Predictable performance
  • ✓ Explicit control flow
  • ✗ Easy to ignore
  • ✗ No type information
  • ✗ Manual propagation

C++23: std::expected

std::expected<T, E> represents either a value of type T (success) or an error of type E (failure).

#include <expected>
#include <string>
#include <iostream>

// Function that might fail
std::expected<double, std::string> safe_divide(double a, double b) {
    if (b == 0.0) {
        return std::unexpected("Division by zero");
    }
    if (std::isnan(a) || std::isnan(b)) {
        return std::unexpected("NaN argument");
    }
    return a / b;
}

// Usage
int main() {
    auto result = safe_divide(10.0, 2.0);
    
    if (result) {
        std::cout << "Result: " << *result << std::endl;
    } else {
        std::cout << "Error: " << result.error() << std::endl;
    }
}

Basic Operations

std::expected<int, std::string> get_value(bool success) {
    if (success) return 42;
    return std::unexpected("Failed");
}

auto result = get_value(true);

// Check state
bool has_value = result.has_value();  // true

// Access value (undefined behavior if empty!)
int value = result.value();           // 42
int v2 = *result;                     // 42 (dereference)

// Safe access with default
int v3 = result.value_or(0);          // 42 (or 0 if error)

// Access error (only valid if has_value() == false)
auto error = result.error();          // UB here since success

Monadic Operations

Chain operations that might fail without explicit error checking.

1. transform

// transform: Apply function to value if present
std::expected<int, std::string> parse_number(const std::string& s);

auto result = parse_number("42")
    .transform([](int n) { return n * 2; })     // 84
    .transform([](int n) { return std::to_string(n); });  // "84"

// If parse_number failed, transforms are skipped
// Error propagates through the chain

2. and_then

// and_then: Chain operations that return expected
std::expected<int, std::string> parse_int(const std::string& s);
std::expected<double, std::string> divide(double a, double b);

auto result = parse_int("100")
    .and_then([](int n) { return divide(n, 2.0); })
    .and_then([](double d) { return divide(d, 5.0); });

// Result: 10.0 or error from any step

3. or_else

// or_else: Error recovery
std::expected<int, std::string> load_config(const std::string& path);

auto config = load_config("config.json")
    .or_else([](const std::string& error) {
        std::cerr << "Failed to load: " << error << std::endl;
        return load_config("config.default.json");
    })
    .or_else([](const std::string&) {
        return std::expected<int, std::string>{42};  // Use default
    });

Practical Example: File Parser

enum class ParseError {
    FileNotFound,
    InvalidFormat,
    OutOfRange
};

struct Config {
    int port;
    std::string host;
    bool use_ssl;
};

std::expected<Config, ParseError> load_config(const std::string& path) {
    return read_file(path)
        .and_then(parse_json)
        .and_then(validate_config)
        .transform([](const json& j) {
            return Config{
                .port = j["port"],
                .host = j["host"],
                .use_ssl = j.value("ssl", false)
            };
        });
}

// Usage with pattern matching (C++23)
auto result = load_config("app.conf");
if (result) {
    const auto& config = *result;
    std::println("Port: {}", config.port);
} else {
    switch (result.error()) {
        case ParseError::FileNotFound:
            std::println("Config file not found");
            break;
        case ParseError::InvalidFormat:
            std::println("Invalid config format");
            break;
        // ...
    }
}

expected vs optional

Featurestd::optional<T>std::expected<T, E>
RepresentsValue or nothingValue or error
Error InfoNo (just empty)Yes (typed error)
Use CaseNullable values, find operationsFallible operations
Monadic Opsand_then, transform, or_elseSame + error() accessor

Best Practices

  • Use expected for operations that can fail with specific errors
  • Use monadic operations to build error-handling pipelines
  • Choose meaningful error types (enums for simple cases, classes for rich info)
  • Don't use expected for truly exceptional circumstances (use exceptions)
  • Avoid .value() without checking first (use value_or or check has_value())