The C++ Error Handling Battleground:
Exceptions vs. std::expected vs. Outcomes

Error handling in C++ has long been a contentious topic. For decades, the community was split between the "C-style" approach (returning error codes and passing output parameters by reference) and C++ Exceptions. Both had significant drawbacks.

Today, modern C++ has expanded the toolbox. We now have powerful alternatives that aim to combine the explicitness of return codes with the expressiveness of exceptions. This article explores the three major players in contemporary C++ error handling: standard Exceptions, the C++23 newcomer std::expected, and the powerful Boost.Outcome library.

1. The Traditionalist: C++ Exceptions

Exceptions are the standard, built-in mechanism for reporting errors that cannot be handled locally. When an error occurs, you throw an object. The runtime then unwinds the stack, looking for a matching catch block, destroying local objects along the way (RAII).

The "Happy Path" Philosophy

The primary design goal of exceptions is to separate error-handling code from mainline logic. In theory, your code reads cleanly, assuming everything goes right, and errors are handled in dedicated sections elsewhere.

// A traditional function using exceptions
double divide(double a, double b) {
    if (b == 0.0) {
        throw std::runtime_error("Division by zero!");
    }
    return a / b;
}

void business_logic() {
    try {
        // Clean "happy path" code
        auto result = divide(10.0, 0.0);
        process(result);
    } catch (const std::exception& e) {
        // Error handling isolated here
        std::cerr << "Error: " << e.what() << '\n';
    }
}

The Pros

The Cons

2. The Modern Challenger: std::expected (C++23)

Entering the standard in C++23, std::expected<T, E> is a vocabulary type designed for operations that usually succeed but might fail. It is a "sum type" (like std::variant) that holds either a value of type T (success) or an error of type E (failure).

It brings error handling back into the type system, making it explicit in the function signature, but without the clunky output parameters of C-style code.

#include <expected>
#include <string>
// Requires C++23 compatible compiler

enum class MathError { DivisionByZero, NegativeSqrt };

// The signature explicitly states: returns a double OR a MathError
std::expected<double, MathError> safe_divide(double a, double b) {
    if (b == 0.0) {
        // Return an unexpected value (the error type)
        return std::unexpected(MathError::DivisionByZero);
    }
    return a / b;
}

void modern_business_logic() {
    auto result = safe_divide(10.0, 0.0);

    if (result.has_value()) {
        // Access the success value with * or .value()
        process(*result);
    } else {
        // Handle the error explicitly
        handle_error(result.error());
    }
}

The Monadic Twist

Where std::expected truly shines is with "monadic operations" like .and_then() and .transform(). These allow you to chain operations that might fail without a pyramid of nested if statements.

// Chaining operations cleanly
auto final_result = get_input()
    .and_then(parse_input)    // if success, run parse_input
    .and_then(calculate)      // if success, run calculate
    .transform(format_output); // if success, convert result
    // If any step failed, final_result holds the first error encountered.

The Pros

The Cons

3. The Power User: Boost.Outcome (and ready-to-go Outcome)

Before C++23 standardized std::expected, there was Boost.Outcome (also available as a standalone, non-Boost library). Outcome is essentially the spiritual predecessor to std::expected, but it is significantly more powerful and flexible.

Outcome was designed specifically for mixed environments where you need to interoperate between libraries that use exceptions and libraries that use return codes. Its primary type is outcome::result<T, E = std::error_code, P = std::exception_ptr>.

It can hold:

  1. A success value (T)
  2. A failure code (E, usually an enum or std::error_code)
  3. A catastrophic exception pointer (P)

Why use Outcome over std::expected?

While std::expected is great for pure modern C++ codebases, Outcome excels as a bridge. It provides seamless mechanisms to convert failure codes *into* thrown exceptions if the caller prefers that style.

#include <boost/outcome.hpp>
namespace outcome = boost::outcome_v2;

// Define an enum and register it with std::error_code system (omitted for brevity)
enum class DbError { ConnectionLost, QueryFailed };

// Typical Outcome signature using std::error_code by default
outcome::result<int> query_database() {
    if (connection_down) {
        // Returns an error state
        return DbError::ConnectionLost;
    }
    return 42; // Returns success
}

void hybrid_consumer() {
    // Option 1: Handle like std::expected
    auto res = query_database();
    if(res) {
       use(res.value());
    } else {
       log(res.error());
    }

    // Option 2: Convert to exception automatically!
    // .value() throws Bad Result Access if it holds an error
    try {
       int val = query_database().value();
    } catch (const std::system_error& e) {
       // Outcome automatically converted the error enum to a system_error
    }
}

The Pros

The Cons

Summary Comparison Table

Mechanism Type of Failure Performance (Failure Case) Explicitness Best Use Case
C++ Exceptions Fatal, Rare, "Run-time" Slow (allocation, stack unwind) Low (Invisible in signature) When failure is truly exceptional and you cannot recover locally. Failsafe.
std::expected (C++23) Business Logic, Common Fast (Zero overhead) High (Visible in signature) The default for modern C++ APIs. Expected failures (file not found, parse error).
Boost.Outcome Hybrid / Interop Fast (Tunable) High Complex library boundaries needing to bridge error codes and exception worlds.

Conclusion: Which to Choose?

The C++ community is moving away from using Exceptions for control flow. The general consensus for modern C++ development is:

  1. Use std::expected (or a polyfill) by default for any failure that is a reasonable outcome of the function call (e.g., parsing failures, network timeouts, validation errors). It makes your APIs honest.
  2. Reserve Exceptions for truly exceptional circumstances. Things like running out of memory (std::bad_alloc), logic errors that should have been caught by assertions, or constructor failures.
  3. Reach for Outcome if you are building complex library infrastructure that needs maximum flexibility to interoperate with older codebases or different user preferences regarding error handling strategies.

Next article: Writing Readable C++ Code