C++11 C++20: using enum C++23: std::to_underlying
C-style enumerations have two design flaws: their enumerators spill into the surrounding scope, and they convert to integers whenever the compiler feels like it. Scoped enumerations — enum class — fix both, and add a third improvement: control over the underlying integer type. In modern code, enum class is the default and plain enum is the exception that needs a justification.
The two flaws, demonstrated
#include <print>
// Unscoped: 'red', 'green', 'blue' are now names in the enclosing scope,
// and any of them silently becomes an int on contact.
enum Color { red, green, blue };
// Scoped: enumerators live inside Status, and nothing converts implicitly.
enum class Status { active, suspended, closed };
int main() {
int paint = red; // compiles - flaw #2 in action
// enum Signal { red }; // error - 'red' already taken: flaw #1
// int s = Status::active; // error - scoped enums don't convert
// if (Status::active == 0) // error - not comparable to int either
auto s = Status::active;
std::println("paint={} active={}", paint, s == Status::active);
}
Scope pollution is not cosmetic. Two unscoped enums in one header cannot both have an enumerator named error or none — a real problem in large codebases and the reason for prefixes like COLOR_RED in C. Implicit conversion is worse: if (get_status() == get_color()) compiles with unscoped enums, comparing quantities that share nothing but being secretly int.
Choosing the underlying type
Every enum is stored as an integer type. Unscoped enums leave it implementation-defined; scoped enums default to int and let you pick:
#include <cstdint>
#include <print>
#include <utility>
// One byte on the wire, guaranteed - suitable for protocols and packed structs.
enum class ErrorCode : std::uint8_t {
none = 0,
not_found = 4,
timeout = 8,
};
int main() {
ErrorCode e = ErrorCode::timeout;
// C++23: std::to_underlying is the blessed way out of the enum.
std::println("code={} storage={} byte(s)", std::to_underlying(e), sizeof(e));
// The pre-C++23 spelling of the same cast:
auto raw = static_cast<std::underlying_type_t<ErrorCode>>(e);
std::println("raw={}", raw);
}
A fixed underlying type also enables forward declaration — enum class ErrorCode : std::uint8_t; in a header, full definition elsewhere — which cuts header dependencies exactly the way forward-declared classes do.
When you do need the number (serialization, indexing, C APIs), the conversion is deliberately loud: std::to_underlying(e) C++23 (in <utility>), which unlike a hand-written static_cast can never pick the wrong integer type.
using enum: scoped safety, unscoped brevity
C++20 Inside a scope, using enum imports all enumerators, which makes switch statements read cleanly without giving up any of the type safety:
#include <print>
#include <string_view>
enum class Status { active, suspended, closed };
std::string_view label(Status s) {
switch (s) {
using enum Status; // scoped to this switch only
case active: return "active";
case suspended: return "suspended";
case closed: return "closed";
}
return "unknown";
}
int main() {
std::println("{}", label(Status::suspended));
}
Keep using enum local — a switch, a function. At namespace scope it recreates the pollution problem enum class was built to solve.
One more switch note: because scoped enums have a known, closed set of enumerators, compilers can warn when a case is missing (-Wswitch, part of -Wall) — but only if you omit the default label. A default on an enum switch trades away that safety net.
Bit flags with scoped enums
The one place unscoped enums used to be more convenient: flag sets, where implicit conversion made read | write just work. With scoped enums you define the operators once, and in exchange the flag type stops accepting arbitrary integers:
#include <print>
#include <utility>
enum class Perm : unsigned {
none = 0,
read = 1 << 0,
write = 1 << 1,
exec = 1 << 2,
};
constexpr Perm operator|(Perm a, Perm b) {
return static_cast<Perm>(std::to_underlying(a) | std::to_underlying(b));
}
constexpr bool has(Perm value, Perm flag) {
return (std::to_underlying(value) & std::to_underlying(flag)) != 0;
}
int main() {
constexpr Perm mode = Perm::read | Perm::write;
std::println("read={} exec={}", has(mode, Perm::read), has(mode, Perm::exec));
}
Guidelines
- Reach for
enum classby default; use plainenumonly when interfacing with C code that requires the implicit conversions. - Specify the underlying type whenever the enum crosses a boundary — files, sockets, packed structs, C APIs — and forward-declare it in headers.
- Convert out with
std::to_underlying(C++23) rather than a rawstatic_castto a hand-picked integer type. - Use
using enuminside aswitchfor readability; skip thedefaultcase so-Wswitchguards future enumerator additions. - Give flag enums their operators via
constexprfree functions; don't fall back to an unscoped enum for convenience.