· 6 min read ·

Why enum class and std::error_code Don't Fit Together, and What That Reveals About C++ Error Handling

Source: isocpp

C++11 shipped two features that should have been natural partners: enum class, which gave C++ properly scoped enumerations that don’t silently convert to integers, and <system_error>, which gave it a portable, extensible error code system. The problem is that <system_error> was designed years before enum class was finalized, built on the assumption that error codes are plain integers that convert implicitly. When the two features met in the same standard, they didn’t fit.

Mathieu Ropert’s recent writeup on isocpp.org works through this gap carefully. The article is worth reading on its own, but it points toward a broader question: why does the integration require so much boilerplate, what exactly does that boilerplate do, and does std::expected in C++23 actually fix the underlying problem or just sidestep it?

The Design Collision

The <system_error> header was drafted during the Boost era, heavily influenced by Boost.System, which was itself written in the mid-2000s. At that time, unscoped enums converting implicitly to int was just how C++ worked. The design of std::error_code relies on that conversion: it stores an integer value paired with a pointer to an std::error_category singleton, and the whole machinery is wired to accept any type that can produce an integer.

enum class took the opposite position. Scoped enumerations are not integers. You cannot pass one where an int is expected, you cannot compare one against an int, and you cannot accidentally mix values from different enums because they occupy distinct types. This was the entire point. The stricter type model was a deliberate improvement over unscoped enums.

These two designs entered C++11 simultaneously but pulling in opposite directions. std::error_code needs implicit integer conversion; enum class refuses to provide it. Bridging the gap requires explicit opt-in machinery.

What the Integration Actually Requires

To use an enum class as an std::error_code, you need three things, and all three must be set up correctly or the integration silently fails.

First, a custom error_category. Every distinct error domain needs a singleton that provides a name and maps integer values to message strings:

class NetworkErrorCategory : public std::error_category {
public:
    const char* name() const noexcept override {
        return "network";
    }
    std::string message(int ev) const override {
        switch (static_cast<NetworkError>(ev)) {
            case NetworkError::timeout:            return "connection timed out";
            case NetworkError::connection_refused:  return "connection refused";
            case NetworkError::host_unreachable:    return "host unreachable";
            default:                                return "unknown network error";
        }
    }
};

const NetworkErrorCategory& network_error_category() {
    static NetworkErrorCategory instance;
    return instance;
}

Second, a make_error_code function that converts your enum into an std::error_code. This function must live in the same namespace as the enum, because the std::error_code constructor template finds it through argument-dependent lookup. Put it in the wrong namespace and ADL won’t find it; the compiler either rejects the conversion or, worse, finds some other overload silently:

namespace net {
    enum class NetworkError : int {
        success           = 0,
        timeout           = 1,
        connection_refused = 2,
        host_unreachable   = 3,
    };

    // Must be here, not in global namespace
    std::error_code make_error_code(NetworkError e) {
        return { static_cast<int>(e), network_error_category() };
    }
}

Third, a specialization of std::is_error_code_enum to tell the std::error_code constructor template that implicit construction from your enum is allowed:

template <>
struct std::is_error_code_enum<net::NetworkError> : std::true_type {};

With all three in place, the machinery works. You can write std::error_code ec = net::NetworkError::timeout; and the implicit construction goes through make_error_code via ADL. Without all three, something breaks, usually without a helpful error message.

This is a significant amount of ceremony for what should be a simple operation. cppreference documents the full mechanism, but the documentation itself is long, which is telling.

The Category Identity Problem

The boilerplate is annoying, but a subtler issue lives underneath it. std::error_category compares by pointer identity. Two error_code values are only considered equal if they hold the same integer value and their category pointers are the same address.

This works fine in a single binary. The singleton pattern guarantees one address per category. But across shared library boundaries, the singleton can be instantiated once per library, producing different addresses for logically identical categories. Two error codes that should compare equal don’t, because they came from different copies of the singleton.

This is a known, documented problem with no clean fix in the current design. Ropert has written about it before, and it remains an open issue. For most desktop applications it doesn’t surface, but in plugin architectures or any system using dlopen-style loading, it can produce baffling comparison failures.

The std::error_condition type exists partly to address cross-category comparisons at the semantic level, representing a portable meaning (“file not found”) rather than a specific implementation value (ENOENT on POSIX, ERROR_FILE_NOT_FOUND on Windows). In practice, most code conflates error_code and error_condition or ignores error_condition entirely, because the distinction is subtle and rarely explained clearly. The standard’s own std::errc enum maps to error_condition, not error_code, which trips up developers who expect std::errc::timed_out to compare directly against their custom error_code values without setting up the default_error_condition override in their category.

What std::expected Changes

std::expected<T, E> entered C++23 via P0323. It represents either a successful value of type T or an error of type E, as a discriminated union. The error type is a full template parameter, which means enum class works directly with no boilerplate:

#include <expected>

enum class ParseError { invalid_input, overflow, empty_string };

std::expected<int, ParseError> parse_int(std::string_view s) {
    if (s.empty()) return std::unexpected(ParseError::empty_string);
    // ... parse ...
    return 42;
}

No category. No make_error_code. No is_error_code_enum specialization. The enum class is the error type, not a source of integers that need wrapping. This is the design that <system_error> should have had if it had been written after enum class existed.

C++23 also added monadic operations to std::expected via P2505, so error propagation can be written without explicit checking at every step:

auto result = parse_int(input)
    .and_then([](int x) -> std::expected<double, ParseError> {
        return x * 3.14;
    })
    .transform([](double d) {
        return std::to_string(d);
    });

if (!result) {
    // handle result.error()
}

and_then chains only on success, propagating the error otherwise. transform maps the success value. or_else handles the error path. These mirror the monadic interface on std::optional, which got the same treatment in C++23 via P0798.

The category identity problem also disappears. Because E is a concrete type and comparison uses normal C++ equality, there are no pointer games and no cross-boundary hazards.

The Tradeoffs That Remain

std::expected is not universally better. A few things to keep in mind:

std::error_code has better ABI stability for library boundaries. It’s a fixed-size structure (integer plus pointer) that doesn’t depend on the definition of your error enum being visible to the caller. std::expected<T, E> exposes the error type in the ABI, which matters if you’re shipping a compiled library and want to maintain binary compatibility across enum changes.

The <system_error> machinery has deep integration with the standard library. std::filesystem::path operations, networking in libraries built on Asio, POSIX wrappers, and platform error codes all speak std::error_code. Bridging between std::error_code and std::expected<T, std::error_code> is mechanical but adds conversion boilerplate at API boundaries.

For code targeting C++11 or C++14, std::expected is unavailable. Boost.Outcome has provided a compatible result<T, E> for years and is widely used in pre-C++23 codebases that want the same pattern.

Where C++26 Takes This

The longer-term work is P0709, Herb Sutter’s proposal for zero-overhead deterministic exceptions. The core idea is a throws keyword that lets functions declare specific exception types that are guaranteed to be returned by value on the error path, with no stack unwinding and no dynamic allocation. This would allow exception-like syntax with value-semantics cost:

// Proposed, not yet standard
int parse(std::string_view s) throws(ParseError);

If P0709 or something like it lands, the entire error_code versus expected debate may eventually be rendered a historical artifact for new code. But that’s a long way out, and the proposal has been in committee discussion for years without reaching the wording stage.

For code being written today against C++23, the practical guidance is clear enough. Use std::expected<T, E> with enum class for new code where you control both the function signature and the caller. Use std::error_code where you need interoperability with the standard library’s error infrastructure or where ABI stability across compiled boundaries matters. Understand the boilerplate requirements for the latter well enough to get the namespace placement right, because that particular mistake doesn’t announce itself loudly.

The gap Ropert describes isn’t going to be patched in <system_error>. The committee has largely moved on to std::expected as the preferred path. The gap will close by attrition as C++23 adoption spreads and new code stops needing the old pattern.

Was this interesting?