C++11 shipped two features that seemed complementary but turned out to be quietly at odds: enum class for type-safe enumeration values, and std::error_code for portable, cross-library error reporting. Mathieu Ropert’s article on the subject names this directly. But understanding why requires looking at what each mechanism was actually designed to do and where their assumptions diverge.
What std::error_code Was Built For
The std::error_code system, defined in <system_error> since C++11, was modeled after POSIX errno and Boost.System. Its core idea is type erasure: it stores an integer value alongside a pointer to an error_category object, making it possible to pass errors across library boundaries without every library needing to know about every other library’s error types. Two error codes from completely different libraries can be compared safely because the category pointer differentiates them.
This is a legitimate design goal. Systems code deals with errors from the OS, third-party SDKs, network stacks, and application logic, and having a single currency for errors that doesn’t create coupling is useful. The Boost.System design that inspired it was solving a real problem.
The catch is that the mechanism for registering a custom error type with std::error_code relies on implicit conversion. For your enum type to behave naturally as an error_code, the library expects you to:
- Specialize
std::is_error_code_enum<T>to inherit fromstd::true_type - Implement a
make_error_code(T)function findable via ADL - Define a custom
error_categorysubclass with aname()andmessage()implementation
With a plain unscoped enum, this works because the enum implicitly converts to int in contexts where that’s needed. The whole system was designed for unscoped enums.
Where enum class Breaks the Protocol
Scoped enums (enum class) were introduced in C++11 to fix the problems with plain enums: name pollution, implicit conversion to integer types, and the lack of a proper scope. They are, on balance, the right default. But they don’t have implicit integer conversion, which creates friction the moment you try to integrate them with std::error_code.
The make_error_code function bridges the two worlds, but now you’re writing ceremony in every direction:
enum class NetworkError {
Timeout = 1,
ConnectionRefused,
HostUnreachable
};
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::ConnectionRefused: return "connection refused";
case NetworkError::HostUnreachable: return "host unreachable";
default: return "unknown network error";
}
}
};
const std::error_category& network_error_category() {
static NetworkErrorCategory instance;
return instance;
}
std::error_code make_error_code(NetworkError e) {
return {static_cast<int>(e), network_error_category()};
}
template <>
struct std::is_error_code_enum<NetworkError> : std::true_type {};
This is forty lines of machinery for three error values. And it still costs you something important: once NetworkError::Timeout goes into an std::error_code, it becomes an integer paired with a category pointer. The original enum type is gone. If you get back an std::error_code, you can compare it against make_error_code(NetworkError::Timeout), but you cannot switch on it or use it in a structured binding. The type safety of enum class bought you nothing at the call site where it matters most.
The message() function receiving an int that you cast back to NetworkError is also a smell. You defined a scoped enum specifically to avoid raw integer casts, and the error_category API hands you a raw integer on the other side.
The Deeper Mismatch
The real problem is not just boilerplate. It is that std::error_code and enum class have opposing philosophies.
enum class says: the type carries meaning; keep it. std::error_code says: erase the type; all errors become (int, category*) so libraries can interoperate without coupling. These goals are not compatible. You can integrate a scoped enum into the std::error_code protocol, but you surrender the thing that made you reach for enum class in the first place.
This is fine if you genuinely need cross-library type-erased error propagation. POSIX errors really do need to flow through generic code that doesn’t know about your NetworkError enum. But most application code is not doing that. Most code is handling errors from a well-defined call site where the error type is known statically. Forcing std::error_code into that context adds complexity without adding value.
For a thorough treatment of the design tradeoffs in the std::error_code system, Andrzej Krzemieński’s error_code - Intuitive Interface walks through what the system was optimized for. It was optimized for the case where you don’t know the error type, not for the case where you do.
What std::expected Changes
C++23 added std::expected<T, E>, a discriminated union that holds either a value of type T or an error of type E. The error type is a full template parameter. You keep your enum class all the way through:
enum class ParseError {
UnexpectedToken,
UnexpectedEof,
InvalidEncoding
};
std::expected<int, ParseError> parse_integer(std::string_view input) {
if (input.empty()) return std::unexpected(ParseError::UnexpectedEof);
// ...
return 42;
}
auto result = parse_integer("123");
if (result) {
use(*result);
} else {
switch (result.error()) {
case ParseError::UnexpectedEof: /* ... */ break;
case ParseError::InvalidEncoding: /* ... */ break;
default: break;
}
}
No category class. No template specialization. No integer casts. The switch works because result.error() returns a ParseError, not an int. The type is preserved end-to-end.
This is significantly closer to what Rust does with Result<T, E>, where E is typically an enum and pattern matching preserves the full type. The key insight both approaches share is that the error type should be a first-class generic parameter, not a type-erased runtime value.
For cases where you need richer error context or hierarchical error handling, Boost.LEAF offers a different model that avoids carrying error information along call stacks at all, instead dispatching it to registered handlers. Boost.Outcome provides a result<T, E> type similar to std::expected but with broader compiler support and additional options for exception interop.
The Legacy Problem
std::error_code is not going away. Too much existing code uses it: the filesystem library (std::filesystem::filesystem_error), networking proposals, ASIO, and any library that needs to interact with OS-level error codes. The std::errc enum that maps to POSIX errors is defined in terms of std::error_code. If you write code that touches those APIs, you deal with std::error_code whether you like it or not.
The practical recommendation that emerges from Ropert’s analysis and the broader community discussion is roughly: use enum class with std::expected as your default error handling story for new code; reach for std::error_code when you need to bridge to OS errors or legacy APIs that speak in those terms; and write thin adapter layers so the type-erased world doesn’t leak throughout your codebase.
The C++ committee is aware of the tension. Proposals around deterministic exceptions (P0709 and its descendants) have explored whether exceptions could be made to carry typed error values with zero-overhead semantics, which would unify the exception and value-return error handling worlds. That work has not yet landed in the standard, but the direction is toward more type information preserved at the handling site, not less.
What This Reveals
The enum class and std::error_code mismatch is a specific instance of a broader pattern in C++ standardization: features added in the same version of the standard that were designed in isolation, with assumptions that conflict at the edges. Both features are good individually. enum class is strictly better than plain enums for almost every use case. std::error_code solved a genuine problem for its target use case. But they were not designed together, and the composition is awkward.
std::expected in C++23 is the first mechanism that actually handles the common case well without requiring you to choose between type safety and cross-library composability. The migration path is gradual: new APIs can adopt std::expected directly while existing std::error_code interfaces can coexist. Converting between the two is mechanical when needed.
For most application-level error handling in new code, the right answer in 2026 is std::expected<T, SomeEnumClass>. The std::error_code infrastructure remains necessary at the boundary with C APIs and OS interfaces, but it should stay at the boundary.