error_code: The Error Code System and Custom Categories
Eventually, every C++ developer faces a fundamental question: when a function fails, how do we communicate that failure to the caller? The language offers three paths: return codes, std::error_code, and exceptions. We won't cover the internal mechanics of exceptions (that's a topic for another time); instead, we focus on the middle path: how the <system_error> error_code / error_category system is designed, why it is designed this way, and how to seamlessly integrate your own module's error codes into this system.
Let's clarify the motivation first. Most are familiar with the C-era errno: you call a system interface, and if it fails, you read a global errno to get an integer, then use strerror(errno) to look up a string description. It works, but the pitfalls are obvious—errno is global mutable state (thread-local storage mitigates half the problem, but the semantics remain awkward), error numbers are raw ints (the compiler can't distinguish between 2 and 99 as an error code versus a normal return value), and error numbers from different libraries can collide. C++11's <system_error> exists to clean up this mess: it doesn't discard the low-cost "integer error number" model of errno, but instead wraps it in two layers of structure to categorize and type error numbers, allowing custom error codes and system error codes to share the same interface.
Trade-offs Between the Three Error Handling Paths
When writing a function that can fail, you will likely choose among three options:
- Raw return codes: The function returns
int, where0is success and non-zero is an error number. This is the most primitive and zero-overhead approach. The problem is that the caller receives anint, and the compiler cannot distinguish whether it is a result or an error code; if you forget to check it, you're flying blind. Furthermore,intlacks classification; a2from one module and a2from another might mean completely different things. - Exceptions:
throwan object, and control flow implicitly jumps to the nearestcatch. The advantage is that the "success path" code remains very clean, and errors automatically propagate up the call stack. The cost is runtime overhead (even if not thrown, some ABIs have table overhead, and constructing the exception object/stack unwinding isn't cheap), and it hides the fact that "it might throw" inside the signature (C++ lacks a mandatorythrowsdeclaration). std::error_code: The function returns a lightweight object (essentially anintplus a pointer to a category), containing "error number + which system this number belongs to". It is explicit—the return type sayserror_code, so the caller knows immediately to check it. It is also zero-overhead—no exceptions, no stack unwinding, and the object is just 16 bytes, which is about the same as stuffing anintinto anexpected<T, error_code>.
This table lays out the trade-offs between the three:
| Dimension | Raw Return Code | error_code | Exception |
|---|---|---|---|
| Explicitness | Poor (int reveals nothing) | Good (type is documentation) | Poor (hidden in signature) |
| Runtime Overhead | 0 | Near 0 (16-byte object) | Yes (even if not thrown, higher if thrown) |
| Error Classification | None | Yes (category) | Yes (exception type) |
| Cross-module/Library | Each does its own thing | Standard unified system | Each does its own thing |
| Failure Ignorable | Easy to forget to check | Easy to forget to check (bool helps half-way) | Cannot ignore (crash if not caught) |
The sweet spot for error_code is scenarios where "failure is normal, expected, and on the hot path": files won't open, networks time out, configuration items are invalid. For these errors, you don't want the overhead of exceptions, but you do want a unified, comparable, queryable error system across modules—this is exactly what <system_error> was built to do.
The Composition of error_code: value + category, separated
Let's first look at what error_code looks like. Inside, it contains just two things:
// Standard: C++11
// std::error_code 的语义(简化)
class error_code {
int value_; // 错误号(整数值)
const error_category* cat_; // 指向某个 category 单例的指针
};One int holds the error number, and a pointer points to the error_category it belongs to. The key design lies in this separation: the error number is just a raw value, but a standalone "2" is meaningless—it could be the POSIX ENOENT (No such file or directory), or it could be "Authentication Failed" in your custom module. Only when paired with "which error numbering system it belongs to" does this "2" have a definite meaning. error_category is the carrier for the concept of an "error numbering system."
error_category is an abstract base class. Each concrete category is a singleton that provides three core capabilities:
name()— The name of this error numbering system (e.g.,"system","generic","my-app").message(ev)— Translates the error number into human-readable text.default_error_condition(ev)/equivalent(...)— The bridge for cross-category comparisons (discussed specifically below).
The standard library comes with two built-in categories:
std::system_category()— Corresponds to the current platform's system error numbers (theerrnoset, likeENOENT/EACCES/ETIMEDOUT...).std::generic_category()— Corresponds to POSIX generic error numbers, stable across platforms (the same set oferrcenum values).
Let's run a minimal example to wrap an errno-style failure into an error_code:
// Standard: C++11
#include <system_error>
#include <iostream>
#include <cstring>
int main()
{
auto* fp = std::fopen("/no/such/file/here", "r");
if (fp == nullptr) {
int e = errno; // 原始错误号
std::error_code ec{e, std::system_category()}; // 包成 error_code
std::cout << "value: " << ec.value() << '\n';
std::cout << "category: " << ec.category().name() << '\n';
std::cout << "message: " << ec.message() << '\n';
}
return 0;
}g++ -std=c++20 -O2 (native GCC 16.1.1) results:
value: 2
category: system
message: No such file or directoryThat 2 is the value of ENOENT, and system_category knows that on the current platform, 2 corresponds to "No such file or directory". Note that we didn't write a single line of strerror; message() looked it up for us—this is the benefit of the category encapsulating the mapping from "number to description".
error_code also has an operator bool: it returns true if the error number is non-zero (indicating an error). A default-constructed error_code has an error number of 0 and a bool value of false (no error). Therefore, checking for errors is just one line: if (ec):
sizeof(error_code) = 16
default error_code bool = 0Sixteen bytes—that is just an int (padded to eight) plus a pointer. Something this lightweight, when passed between functions or wrapped in an expected, incurs almost no overhead.
errc, make_error_code, and Cross-Category Comparison
Here is a counterintuitive point that almost every beginner stumbles over: Is the standard library's cross-platform error code enumeration, std::errc, actually an "error code" or an "error condition"?
The answer is the latter. An enumeration value like errc::no_such_file_or_directory is not an error_code, but an error_condition. We must clearly distinguish these two concepts:
error_code— A concrete, platform-specific error number. For example,2undersystem_categoryisENOENTon Linux, but the value might differ on other platforms.error_condition— An abstract, portable error condition. For example,no_such_file_or_directoryundergeneric_categoryretains the same meaning regardless of the platform.
errc is an enumeration for error_condition. The standard library marks it as a condition enum using is_error_condition_enum<std::errc>. We can verify this directly using a type trait:
// Standard: C++11
std::cout << "is_error_code_enum<errc> = "
<< std::is_error_code_enum<std::errc>::value << '\n';
std::cout << "is_error_condition_enum<errc> = "
<< std::is_error_condition_enum<std::errc>::value << '\n';is_error_code_enum<errc> = 0
is_error_condition_enum<errc> = 1The consequence is straightforward: std::error_code ec = std::errc::timed_out; fails to compile. errc is not a code enumeration, so the path enabling the implicit construction of error_code is not available. To convert errc into error_code, we must explicitly call std::make_error_code:
// Standard: C++11
auto ec = std::make_error_code(std::errc::timed_out); // 造出来是 generic_category
std::cout << "value=" << ec.value()
<< " cat=" << ec.category().name() << '\n';
// value=110 cat=generic (110 就是 POSIX 的 ETIMEDOUT)So, where can we use errc given that it is a condition? The answer is comparison. error_code overloads ==, so we can compare it directly with an errc:
// Standard: C++11
int e = ENOENT; // 2
std::error_code sys_ec{e, std::system_category()};
auto generic_ec = std::make_error_code(std::errc::no_such_file_or_directory);
std::cout << "sys_ec == generic_ec (code==code) ? "
<< (sys_ec == generic_ec) << '\n';
std::cout << "sys_ec == errc::no_such_file_or_directory (code==errc) ? "
<< (sys_ec == std::errc::no_such_file_or_directory) << '\n';sys_ec == generic_ec (code==code) ? 0
sys_ec == errc::no_such_file_or_directory (code==errc) ? 1Notice the difference between these two results; this is the entire reason default_error_condition exists:
sys_ec == generic_eccompares whether "twoerror_codeobjects are exactly equal" (equal value and equal category). Since one belongs tosystemand the other togeneric, the categories differ, resulting in0(not equal).sys_ec == errc::...takes a different path:errcis implicitly constructed into anerror_conditionfirst. During comparison,system_category'sdefault_error_condition(2)maps this system error number to the correspondinggenericcondition—which happens to beno_such_file_or_directory—so they are equal.
In other words, default_error_condition is the category's way of declaring "this specific error number is equivalent to which generic error condition." It builds a bridge between "platform-specific error codes" and "portable error conditions": if you get ENOENT from system_category on Linux and compare it with someone else's no_such_file_or_directory from generic_category, they are equal—because they are semantically the same thing. This is why, in practice, error checking almost always uses the ec == std::errc::xxx style, rather than comparing against another error_code: the former works across categories and platforms, while the latter is strictly bound to the same category.
errc is not error_code
errc is an error_condition enum, not an error_code enum. std::error_code ec = std::errc::x; fails to compile; you must use std::make_error_code(std::errc::x). However, ec == std::errc::x compiles and works correctly—the == path uses default_error_condition equivalence and does not require errc to be a code enum. This distinction is central to the design of <system_error>. If you mix them up, you'll either get a compilation failure or write a comparison that "looks correct but never matches."
system_error: Wrapping error_code in an Exception
error_code provides an explicit return path, but sometimes we still want the "automatic bubbling up" semantics of an exception. For example, deep within a call stack, if a low-level error_code fails, we might not want to manually propagate it up layer by layer, preferring to throw immediately. <system_error> provides a ready-made exception class, std::system_error, which wraps an error_code internally:
// Standard: C++11
#include <system_error>
#include <iostream>
#include <cstring>
int main()
{
try {
errno = ENOENT;
throw std::system_error(
std::error_code{ENOENT, std::system_category()},
"打开配置文件失败");
} catch (const std::system_error& e) {
std::cout << "what(): " << e.what() << '\n';
std::cout << "code value: " << e.code().value() << '\n';
std::cout << "code msg: " << e.code().message() << '\n';
std::cout << "category: " << e.code().category().name() << '\n';
}
return 0;
}what(): 打开配置文件失败: No such file or directory
code value: 2
code msg: No such file or directory
category: systemwhat() concatenates the context string we provide with error_code::message(), making the issue immediately clear during debugging. Once caught, we can extract the internal error_code using e.code() and continue with the standard logic of value()/category()/== errc. This serves as the bridge between error_code and exceptions: we can use the low-overhead error_code for returns at the low level, and at the boundary, decide that "this error is worth interrupting the control flow" by throwing system_error{ec, "..."} to promote it to an exception. Conversely, catching system_error allows us to retrieve the original error_code. The two paths are interoperable, not mutually exclusive.
The Standard Library uses this pattern most extensively in <filesystem> (C++17). Every function in std::filesystem that might fail has two overloads: one that throws a filesystem_error (which inherits from system_error), and another that accepts a std::error_code& output parameter and does not throw:
// Standard: C++17
#include <filesystem>
#include <system_error>
#include <iostream>
namespace fs = std::filesystem;
int main()
{
std::error_code ec;
fs::file_size("/no/such/path", ec); // 不抛重载:错误塞进 ec
if (ec) {
std::cout << "file_size 失败: " << ec.message()
<< " (cat=" << ec.category().name() << ")\n";
}
return 0;
}file_size 失败: No such file or directory (cat=system)The caller gets to choose: if we want exceptions to interrupt the flow, we call the version without ec; if we want to handle it ourselves and avoid exceptions breaking the control flow, we pass ec. This "dual API" pattern can be seen everywhere in filesystem and asio. Essentially, it treats error_code as an "optional alternative to exceptions," leaving the decision to the caller. The custom category we discuss below is designed to integrate our own modules into this system where "error codes can be everywhere."
Custom category: Building an Error Code System from Scratch
This is the core of this article. The cleverest part of the standard library's error_code system design is that it isn't just for system errors—any module can register its own error_category, define its own set of error numbers, and then use the unified type error_code to carry them. We can operate on them using the same message()/== errc interfaces. Your module's errors are on equal footing with POSIX errors in terms of type.
Let's build one from scratch. Suppose we are writing a network login module with the following possible errors: network disconnection, authentication failure, timeout, and packet format error. Our goal is to make login() return std::error_code, allowing the caller to check with ec == MyErrc::kAuthFailed, retrieve a Chinese description with ec.message(), and even make kTimeout automatically equivalent to the standard errc::timed_out.
Step 1: Define the Error Code Enum
// Standard: C++11
#include <system_error>
#include <string>
#include <iostream>
enum class MyErrc {
kSuccess = 0,
kNetworkDown = 10,
kAuthFailed = 11,
kTimeout = 12,
kBadPayload = 13,
};Note that values start at zero, and zero is reserved for "success"—this aligns with the error_code::operator bool convention (where non-zero indicates an error).
Step 2: Specialize is_error_code_enum to enable implicit conversion
Having just an enum isn't enough; the standard library doesn't know it's an "error code enum". We must specialize std::is_error_code_enum to mark it as true, so that error_code enables the implicit construction path from the enum:
// Standard: C++11
namespace std {
template <>
struct is_error_code_enum<MyErrc> : true_type {};
} // namespace stdThis step acts as the "switch" connecting our custom enum to the error_code system. Previously, we verified that the switch for std::errc is 0 (because it is a condition enum), so errc cannot implicitly construct an error_code. Since we specialized our MyErrc to true, MyErrc can.
Step 3: Write a Custom Category Singleton
A category is a class inheriting from std::error_category that implements those virtual functions. It must be a singleton—because error_code stores a pointer to the category internally, and comparing two error_code objects for the same category compares the pointers. Therefore, there must be only one instance of each category in the entire process:
Expand (34 lines)Collapse
// Standard: C++11
class MyCategory : public std::error_category {
public:
const char* name() const noexcept override {
return "my-app";
}
std::string message(int ev) const override {
switch (static_cast<MyErrc>(ev)) {
case MyErrc::kSuccess: return "成功";
case MyErrc::kNetworkDown: return "网络不可达";
case MyErrc::kAuthFailed: return "鉴权失败";
case MyErrc::kTimeout: return "操作超时";
case MyErrc::kBadPayload: return "报文格式错误";
default: return "未知错误";
}
}
// 把自定义错误号映射到通用 error_condition,实现跨 category 等价
std::error_condition default_error_condition(int ev) const noexcept override {
switch (static_cast<MyErrc>(ev)) {
case MyErrc::kTimeout:
return std::make_error_condition(std::errc::timed_out);
default:
return {ev, *this}; // 其他用本 category 自身
}
}
};
// 单例工厂:全进程唯一实例
const std::error_category& my_category() {
static MyCategory instance;
return instance;
}message() translates the error number into human-readable text—saving us from writing ec.message() ourselves. The default_error_condition() step is optional but critical: we declare that "my kTimeout is equivalent to the standard errc::timed_out". The consequence is that when the caller receives an error_code containing MyErrc::kTimeout, they can check it directly using if (ec == std::errc::timed_out). This bridges the semantic gap across categories and between "my module" and the standard library.
Step 4: Write the make_error_code overload
With the switch enabled and the category written, the final step is to provide a make_error_code(MyErrc) function to tell the standard library "how to construct an error_code when encountering MyErrc". This function is found via ADL (Argument-Dependent Lookup), so it should either be placed in the namespace where MyErrc resides or in the std namespace (the former is officially recommended):
// Standard: C++11
std::error_code make_error_code(MyErrc e) {
return {static_cast<int>(e), my_category()};
}With these four steps in place, our custom error code system is complete. Now let's write a function that uses it:
// Standard: C++11
std::error_code login(bool network_ok, bool password_ok) {
if (!network_ok) return MyErrc::kNetworkDown; // 隐式转 error_code
if (!password_ok) return MyErrc::kAuthFailed;
return MyErrc::kSuccess;
}Note the line return MyErrc::kNetworkDown;. Since the is_error_code_enum specialization from step two evaluates to true, the error_code's enabling constructor is activated. The compiler automatically calls make_error_code from step four to convert it into an error_code. Implicit conversion works here, which contrasts with errc's inability to implicitly construct an error_code. This is the practical difference between a "code enum" and a "condition enum".
Let's run the complete example:
Expand (25 lines)Collapse
// Standard: C++11
int main()
{
auto ec1 = login(false, true);
std::cout << "login(false,true):\n";
std::cout << " value = " << ec1.value() << '\n';
std::cout << " category = " << ec1.category().name() << '\n';
std::cout << " message = " << ec1.message() << '\n';
std::cout << " bool(ec) = " << static_cast<bool>(ec1) << " (非0=有错)\n";
auto ec2 = login(true, false);
std::cout << "\nlogin(true,false): " << ec2.message()
<< " (bool=" << static_cast<bool>(ec2) << ")\n";
auto ec3 = login(true, true);
std::cout << "login(true,true): " << ec3.message()
<< " (bool=" << static_cast<bool>(ec3) << ")\n";
std::cout << "\n--- 跨 category 等价性 ---\n";
std::error_code tc{static_cast<int>(MyErrc::kTimeout), my_category()};
std::cout << "kTimeout message: " << tc.message() << '\n';
std::cout << "tc == errc::timed_out ? " << (tc == std::errc::timed_out)
<< " (1=default_error_condition 映射后相等)\n";
return 0;
}Compiled with g++ -std=c++20 -O2 -Wall -Wextra, and the output is:
login(false,true):
value = 10
category = my-app
message = 网络不可达
bool(ec) = 1 (非0=有错)
login(true,false): 鉴权失败 (bool=1)
login(true,true): 成功 (bool=0)
--- 跨 category 等价性 ---
kTimeout message: 操作超时
tc == errc::timed_out ? 1 (1=default_error_condition 映射后相等)With this, we have a completely custom error code system that integrates seamlessly with the standard library. Let's review what each of these four steps actually does:
- Define the enum — Determine the error values, reserving
0for success. - Specialize
is_error_code_enum— Flip the switch to tell the standard library, "This enum is an error code enum." - Write the category singleton — Provide
name,message, anddefault_error_conditionto encapsulate the "meaning" of the error values and their "cross-system equivalence." - Write the
make_error_codeoverload — Tell the standard library how to construct anerror_codefrom the enum, found via ADL.
The category must be a singleton
When error_code::operator== checks for "same category," it compares pointers. If your category has more than one instance, two error_code objects that are logically the "same category" will compare as unequal. Therefore, always return the category using a static local variable inside a function to guarantee a unique instance for the entire process. Missing this step leads to the bizarre bug where "it's clearly the same error, but the comparison says otherwise."
Working with expected: The Modern Approach to Error Code Systems
At this point, you might be wondering: if login() returns error_code directly, how do we pass back the "successful login token" or other results when it succeeds? error_code only holds errors, not values. This is exactly where std::expected<T, E> comes into play—by setting E to error_code, a single type can express both "the successful value" and "the failure error code":
Expand (30 lines)Collapse
// Standard: C++23
#include <expected>
#include <system_error>
#include <iostream>
std::expected<int, std::error_code> read_sensor(int id)
{
if (id < 0) {
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
}
if (id > 100) {
return std::unexpected(std::make_error_code(std::errc::result_out_of_range));
}
return id * 2; // 成功:隐式构 expected
}
int main()
{
if (auto r = read_sensor(5); r) {
std::cout << "read_sensor(5) = " << *r << '\n';
}
if (auto r = read_sensor(-1); !r) {
std::cout << "read_sensor(-1) 失败: " << r.error().message()
<< " (cat=" << r.error().category().name() << ")\n";
}
if (auto r = read_sensor(200); !r) {
std::cout << "read_sensor(200) 失败: " << r.error().message() << '\n';
}
return 0;
}read_sensor(5) = 10
read_sensor(-1) 失败: Invalid argument (cat=generic)
read_sensor(200) 失败: Numerical result out of rangeThe sweet spot of using E = std::error_code is this: your error type is a standard, lightweight object of 16 bytes (we verified earlier that sizeof(error_code) == 16), which is about the same size as stuffing in an int, so it won't bloat expected. At the same time, r.error().message() gives you a readable description directly, and r.error() == std::errc::timed_out allows for cross-system comparison. For custom modules, just set E to an error_code with your own category—the four-step framework we built above plugs straight into expected without modification.
Here's a small pitfall to watch out for: std::unexpected(std::errc::x) does not work. Since errc is a condition enum, unexpected(errc) will deduce the error slot's type as errc instead of error_code, which doesn't match expected<T, error_code> and fails to compile. You must explicitly use std::make_error_code to wrap errc. Custom MyErrc enums, however, can use return std::unexpected(MyErrc::kBoom); directly—because they implicitly convert to error_code (as verified earlier), and the conversion happens during unexpected's construction. The distinction between code enums and condition enums shows up here yet again.
We broke down the full mechanism of expected (construction, monadic chaining, and performance comparison against exceptions) in Post 64, so here we focus only on how it interfaces with error_code. To summarize in one sentence: expected<T, error_code> welds together modern "value-or-error" typed error handling with the standardized, categorized, cross-system error codes of <system_error>—this is one of the cleanest combinations for error handling in modern C++.
Common Pitfalls
Let's round up the places where things easily go wrong based on our journey above; all of these have been verified through testing:
Don't confuse errc with error_code
std::errc is an error_condition enum (is_error_condition_enum == 1, is_error_code_enum == 0), so std::error_code ec = std::errc::x; fails to compile. To construct an error_code, use std::make_error_code(std::errc::x), which results in a code under generic_category. However, comparisons like ec == std::errc::x work fine—they rely on default_error_condition equivalence and don't require errc to be a code enum.
code==code compares value+category, not semantics
error_code{ENOENT, system_category()} == make_error_code(errc::no_such_file_or_directory) results in false—one belongs to system, the other to generic; different categories mean immediate inequality. Semantically they are the same, so to compare them, use the condition path: ec == errc::no_such_file_or_directory. Don't expect direct == between two error_code objects to perform a semantic comparison.
Categories must be singletons, or comparisons break
error_code compares "same category" by comparing pointers. If your category class isn't implemented as a singleton (e.g., returning a temporary object each time), two error_code objects that should be equal will compare as unequal. Always use a function-local static variable to return the category reference.
unexpected(errc) fails to compile
When stuffing errors into std::expected<T, std::error_code>, std::unexpected(std::errc::x) deduces an unexpected<errc>, which doesn't match the type expected<T, error_code>. You need std::unexpected(std::make_error_code(std::errc::x)). Custom code enums (like MyErrc) can omit make_error_code—they implicitly convert to error_code.
Summary
The <system_error> framework essentially wraps the low-cost "integer error number" model of errno in a "categorization + typing" structure. This allows it to be zero-overhead, consistent across modules, and seamlessly integrated with custom error codes. Let's recap the key conclusions:
- Three tiers of error handling: Bare return codes (most primitive, uncategorized),
error_code(explicit, zero-overhead, categorized, standard unified), and exceptions (implicit control flow, overhead, automatic bubbling). The sweet spot forerror_codeis "where failure is normal, in hot paths, and needs cross-module consistency." error_code= value + category: The error number is a raw value, while the category is a singleton providingname/message/default_error_condition. Separating the two ensures a singleinthas a definite meaning only when paired with "which system it belongs to." The standard providessystem_category(platform errno) andgeneric_category(POSIX generic).errcis a condition, not a code:std::errcis anerror_conditionenum and cannot implicitly construct anerror_code(requiresmake_error_code), butec == errc::xworks—viadefault_error_conditionequivalence, which acts as the bridge for cross-category and cross-platform comparison.system_errorexception wrapserror_code: Useerror_codefor low-cost returns at the bottom layer, and promote to an exception at the boundary withthrow system_error{ec, "..."}; the "throwing/non-throwing dual API" offilesystemis the standard practice of this pattern.- Four steps to custom categories: 1. Define enum (reserve 0 for success); 2. Specialize
is_error_code_enum<E>totrue(enable implicit conversion); 3. Write category singleton (name/message/optionaldefault_error_condition); 4. Writemake_error_code(E)overload (found via ADL). Once complete, custom error codes are on equal footing with POSIX errors. - Pair with
expected<T, error_code>: Welds typed "value-or-error" handling with standardized error codes; the 16-byteerror_codeasEhas near-zero overhead. Note thatunexpected(errc)fails to compile whileunexpected(MyErrc)works—another distinction between code enums and condition enums.
In the next post, we'll switch perspectives and look at another error handling paradigm outside of <system_error>. If you're interested, you can revisit Post 64 for the full mechanism of expected, or check out the <filesystem> post to see how this "dual API" pattern lands in a real standard library component.
References
- cppreference: std::error_code — value + category structure,
operator bool, comparison semantics - cppreference: std::error_category — abstract base class with
name/message/default_error_condition - cppreference: std::errc — POSIX generic error condition enum (note it is an
error_conditionenum) - cppreference: std::system_error — exception class wrapping
error_code,filesystem_errorinherits from it - cppreference: std::is_error_code_enum — trait to enable implicit enum to
error_codeconstruction (step 2 for custom categories)