C++ enum class | Scoped Enumerations Explained
이 글의 핵심
Strongly typed scoped enums in C++11: no implicit int conversion, explicit underlying types, switch hygiene, and bit flags with constexpr helpers.
Introduction: old enum versus enum class
If you learned C first, the old unscoped enum probably felt like a free lunch: a bag of names that behaved like almost-integers. The catch is the spillover. Enumerator names live in the enclosing scope, they promote to int without a fuss, and they happily mingle with other enums and raw integers. That is fine for a small translation unit; it gets expensive when a refactor turns “add these two things” into “why is my state mixed with a color.”
C++11 added scoped enumerations—enum class, sometimes called strong enums—where you write Color::Red instead of hoping Red is unique, where there is no silent conversion to int, and where you can pin storage with the same Base-style syntax you use elsewhere: enum class E : std::uint8_t { ... }. The rest of this post is the stuff I wish someone had put in one place when I was still untangling legacy headers.
This article walks through syntax, safety, storage, conversions, switch patterns, bitwise uses, serialization, reflection options, standard library helpers, C++20/C++23 conveniences, and practical patterns (state machines, error codes)—plus what I actually do in real codebases.
enum class syntax: scoped enumerations
A scoped enumeration is introduced with enum class (or the equivalent enum struct):
enum class HttpStatus {
Ok = 200,
NotFound = 404,
InternalError = 500
};
HttpStatus s = HttpStatus::Ok; // must use qualified name
So far, so good: the labels live under HttpStatus::…, you cannot shove a HttpStatus into an int without saying so, and if you never specify a base type the compiler still picks int, just like a classic unscoped enum without a fixed type. We will talk about casts in a bit.
You can combine class/struct keyword with an explicit underlying type:
enum class Priority : std::uint8_t { Low, Normal, High };
This is the idiomatic form when you care about struct size, wire format, or database column width.
Type safety: no implicit conversions
The main semantic difference from unscoped enum is that enum class values are distinct types that do not decay to int:
enum class Status { Idle, Busy };
enum class Color { Red, Green };
Status s = Status::Idle;
// int x = s; // ill-formed
// Color c = s; // ill-formed
// if (s == Color::Red) {} // ill-formed (different types)
if (s == Status::Idle) { /* ok */ }
Unscoped enumeration can implicitly convert to integer types (and the usual arithmetic conversions apply), which is how older code could accidentally add a “status” to a “color” after a refactor. With enum class, those mistakes are caught at compile time.
When you do need an integer—indices, APIs, logging—use an explicit conversion (static_cast, std::to_underlying in C++23) so the intent is visible in code review.
Forward declaration: declaring enums without bodies
You can forward-declare a scoped enumeration if the underlying type is known (fixed) at the point of declaration:
enum class Channel : std::uint8_t; // OK: underlying type fixed
enum class Channel : std::uint8_t {
A, B, C
};
If you omit the underlying type in the forward declaration, the enum is an incomplete type until the definition provides the list of enumerators (and the default underlying type is still int when the definition appears).
Practical uses:
- Headers that only pass
Channelby reference or pointer can include a minimal forward declaration and include the full definition only whereswitchor enumerator names are needed. - Reducing compile-time dependencies in large codebases.
Typical pitfall: forward-declaring enum class E; without : type was not valid until the rules were clarified; in current C++, use the fixed underlying type form for portable forward declarations across translation units.
Underlying type: specifying storage
Use an explicit underlying type when you care about:
- ABI / wire format (stable size on the network or on disk).
- Database columns (
TINYINTvsINT). - Padding and struct layout (tight packing next to other
uint8_tfields). - Interoperability with C or other languages.
enum class FileMode : std::uint32_t {
Read = 1u << 0,
Write = 1u << 1,
Append = 1u << 2
};
If you omit : type, the underlying type is int, which may be wider than you need.
Signed vs unsigned: Pick the same signedness as the domain. Flags are almost always unsigned. Counting or error codes that may use negative sentinel values need a signed underlying type.
Scoped vs unscoped: how I think about it
If you have used both styles, the difference is not subtle. Unscoped enum dumps enumerator names into the surrounding scope, so Red can collide with anything else named Red. It also promotes to int without asking, which is how a “status” and a “color” could accidentally meet in an expression after a messy refactor. enum class fixes that: you always write Color::Red, and the type does not silently become an integer.
Both forms can use a fixed underlying type in modern C++, and both can be forward-declared when the width is nailed down. Where they diverge for day-to-day work is defaults: for new code, I reach for enum class unless I am glueing to a C API, a legacy header, or macros that assume old-style enumerators. In those cases I keep the unscoped enum at the boundary and wrap it if I can.
Refactoring at scale: we migrated 500 enums to enum class
A few years ago I was on a codebase that had grown up with C-style unscoped enums everywhere—RPC IDs, UI states, parser tokens, half of them duplicated names across modules. We did not flip the whole tree in one heroic commit. We migrated in layers: touch a subsystem when it was already open for a feature, add enum class at the type boundary, then peel call sites with qualified names and explicit casts where integers escaped. By the time we stopped counting, we had moved on the order of five hundred enum declarations to enum class, not because the number mattered for its own sake, but because that was how many places the compiler could now catch silly mistakes.
The wins were boring in a good way: fewer name clashes, fewer “helpful” implicit conversions in code review, and a clear place to hang switch exhaustiveness and string conversion. The cost was real too—grep churn, occasional template or macro surprises, and teaching the team to type Foo::Bar until it was muscle memory. If you are considering a similar migration, batch it with real work, lean on the type checker, and do not stress about reaching a round number; the goal is safer edges, not a blog-friendly total.
Explicit conversion: static_cast and safe patterns
To use an enum class as an integer, convert explicitly:
enum class Weekday { Mon, Tue, Wed, Thu, Fri };
Weekday d = Weekday::Wed;
auto index = static_cast<std::underlying_type_t<Weekday>>(d);
C++23 adds std::to_underlying, which is equivalent but names the intent and avoids accidentally casting to the wrong width:
#include <utility>
auto index = std::to_underlying(d); // C++23
For bounded conversions back from integers, validate before casting:
constexpr std::optional<Weekday> weekday_from_int(int v) {
if (v < 0 || v > 4) return std::nullopt;
return static_cast<Weekday>(v);
}
This prevents undefined behavior when static_cast would produce an invalid enumeration value.
enum class in switch: complete patterns
switch on enum class is straightforward: every case label uses the scoped name.
std::string_view describe(HttpStatus s) {
switch (s) {
case HttpStatus::Ok: return "ok";
case HttpStatus::NotFound: return "not found";
case HttpStatus::InternalError: return "error";
}
return "unknown";
}
Exhaustiveness and default
Compilers can warn if not all enumerators are handled (-Wswitch-enum on GCC/Clang). You may add:
default: std::unreachable(); // C++23 <utility>, or compiler intrinsics
—or return a fallback string for forward compatibility when new enumerators may be added later.
Visitors with std::visit (related pattern)
If you model a variant of states, enum class often labels the active alternative. The same discipline—one case per enumerator—applies.
Operators: overloading for enum class
Unlike unscoped enums, enum class does not automatically get arithmetic or bitwise operators. Define them when the domain is numeric (e.g., flags, dimensions).
enum class Dim : std::uint8_t { W, H };
constexpr Dim operator++(Dim d) noexcept {
return (d == Dim::W) ? Dim::H : Dim::W;
}
For ordering (e.g., LogLevel), you can overload operator< on the enum type or compare underlying values in a named function to keep comparisons in one place.
enum class LogLevel : std::uint8_t { Trace, Debug, Info, Warn, Error, Fatal };
constexpr bool operator<(LogLevel a, LogLevel b) noexcept {
return static_cast<std::underlying_type_t<LogLevel>>(a) <
static_cast<std::underlying_type_t<LogLevel>>(b);
}
Then if (current >= minLevel) works as expected for ordered severities.
Flags and bitmasks with enum class
Bit sets are a common use case. Start with unsigned underlying types and distinct power-of-two values:
enum class Permission : std::uint32_t {
None = 0,
Read = 1u << 0,
Write = 1u << 1,
Execute= 1u << 2
};
Provide constexpr overloads for |, &, ^, ~ that cast through the underlying type:
constexpr Permission operator|(Permission a, Permission b) noexcept {
using U = std::underlying_type_t<Permission>;
return static_cast<Permission>(static_cast<U>(a) | static_cast<U>(b));
}
constexpr Permission operator&(Permission a, Permission b) noexcept {
using U = std::underlying_type_t<Permission>;
return static_cast<Permission>(static_cast<U>(a) & static_cast<U>(b));
}
constexpr bool has(Permission set, Permission bit) noexcept {
return (set & bit) == bit;
}
Note: Do not cast arbitrary integers into the enum class unless they are valid combinations; for flags, composite values are expected, but single-enum APIs should still document allowed sets.
Serialization: to and from strings
Most protocols and config files use strings. A simple, maintainable approach is a central map from enumerator to string:
#include <string_view>
#include <unordered_map>
enum class Env { Dev, Staging, Prod };
inline std::string_view to_string(Env e) {
switch (e) {
case Env::Dev: return "dev";
case Env::Staging: return "staging";
case Env::Prod: return "prod";
}
return "unknown";
}
inline std::optional<Env> parse_env(std::string_view s) {
static const std::unordered_map<std::string_view, Env> table = {
{"dev", Env::Dev}, {"staging", Env::Staging}, {"prod", Env::Prod}
};
auto it = table.find(s);
if (it == table.end()) return std::nullopt;
return it->second;
}
For JSON libraries (e.g. nlohmann/json), macro-based registration can reduce boilerplate:
// NLOHMANN_JSON_SERIALIZE_ENUM(Env, {{Env::Dev, "dev"}, ...})
Keep one source of truth for names to avoid drift between wire format and logs.
Reflection: enum-to-string mapping without external tools
C++ does not yet offer full compile-time reflection for enumerators. Common patterns:
- Hand-written
switch(clearest, easiest to optimize, best for small sets). - X-macro lists that generate both the
enum classandto_stringin one place. - Third-party libraries (next section).
X-macros example
#define COLOR_LIST \
X(Red) \
X(Green) \
X(Blue)
enum class Color {
#define X(name) name,
COLOR_LIST
#undef X
};
inline const char* to_string(Color c) {
switch (c) {
#define X(name) case Color::name: return #name;
COLOR_LIST
#undef X
}
return "unknown";
}
This pattern keeps names synchronized at the cost of preprocessor usage.
magic_enum: third-party library
magic_enum is a widely used header-only library that provides enum_name, enum_cast, iteration, and more, using compiler-specific techniques (not standard reflection).
#include <magic_enum.hpp>
enum class Status { Idle, Running, Stopped };
std::string_view name = magic_enum::enum_name(Status::Running);
auto s = magic_enum::enum_cast<Status>("Idle");
I reach for it when the alternative is a giant hand-maintained switch and I need names or iteration in a hurry. I back off when the enum sits on a public API boundary and I want the string contract spelled out in tests, or when compile times are already on fire—magic_enum is not free, and it depends on how far the compiler is willing to go, which means edge cases are real.
Plenty of teams, mine included, still use plain switch plus tests for wire-stable strings. Pick what your repo’s stomach can handle.
Standard library: std::underlying_type and friends
<type_traits> provides:
using U = std::underlying_type_t<MyEnum>; // the fixed or default integer type
static_assert(std::is_enum_v<MyEnum>);
C++23 finally gives you std::to_underlying(e) so you are not hand-rolling static_cast to the “whatever this enum stores in” type, plus std::is_scoped_enum_v and std::is_unscoped_enum_v for generic code that should treat the two families differently. I use those most in logging helpers and serializers where one as_int should work without accidentally widening the wrong thing.
template <class E>
constexpr auto as_int(E e) {
static_assert(std::is_enum_v<E>);
return std::to_underlying(e); // C++23
}
C++20 using enum: shortcut syntax
C++20 allows importing enumerators into a local scope with using enum:
void f(HttpStatus s) {
using enum HttpStatus;
switch (s) {
case Ok: break; // unqualified names
case NotFound: break;
case InternalError: break;
}
}
This is convenient for large switch statements but reduces clarity at a distance—readers must know which using enum is in effect. Prefer it in localized blocks, not at namespace scope in headers.
C++23 features relevant to enumerations
Besides std::to_underlying, std::is_scoped_enum, and std::is_unscoped_enum, practical C++23 improvements that often appear next to enum code include:
std::unreachable()indefaultbranches of exhaustiveswitchto document intent.- Feature-test macros such as
__cpp_lib_to_underlyingfor portable fallbacks.
There is no single “new enum class keyword” in C++23—the evolution is mostly in library support and safer patterns around existing language rules.
What I do: habits that stuck
When I start a new type that is really “one of a small fixed set,” I default to enum class unless something external forces the old shape. If the enum leaves the process—network, disk, another team’s ABI—I give it an explicit underlying type so nobody’s int width becomes a silent contract.
I keep enumerators stable when I can, note reserved ranges in a comment if we might extend the set, and turn on switch exhaustiveness warnings so a new case is a compile-time nudge rather than a production surprise. String conversion lives next to the type (free functions or a tiny helper), and any path that casts an untrusted integer back to the enum gets a range check or std::optional—I have been burned enough by “it was in range on my machine.”
Common mistakes (and what I actually did about them)
Forgetting the Enum:: prefix is the classic first-week error; IDE completion and the occasional using enum in a tight local block fix most of it. If you expect an enum class to compare cleanly to int, the compiler will educate you—use std::to_underlying or a small domain operator< for ordered sets.
static_cast from a random integer without validation is undefined behavior if the value is not a valid enumerator; I treat parsing from wire or JSON like any other untrusted input. Duplicated string tables love to drift from the JSON schema, so I either X-macro once, generate, or keep a single map. Huge enums in hot public headers are a rebuild tax; sometimes a stable uint32_t code on the wire and a private enum class inside the module is the kinder move. And if you want flag-style | on an enum class, you need the constexpr operators—otherwise the language is just doing its job by saying no.
Real examples: state machines and error codes
State machine
enum class ConnectionState {
Disconnected,
Connecting,
Connected,
Reconnecting,
Failed
};
class NetworkClient {
ConnectionState state_{ConnectionState::Disconnected};
public:
void connect() {
if (state_ == ConnectionState::Disconnected)
state_ = ConnectionState::Connecting;
}
bool is_connected() const {
return state_ == ConnectionState::Connected;
}
std::string_view state_string() const {
switch (state_) {
case ConnectionState::Disconnected: return "Disconnected";
case ConnectionState::Connecting: return "Connecting";
case ConnectionState::Connected: return "Connected";
case ConnectionState::Reconnecting: return "Reconnecting";
case ConnectionState::Failed: return "Failed";
}
return "Unknown";
}
};
Error codes
enum class ErrorCode : std::int32_t {
Success = 0,
InvalidArgument = 1,
NotFound = 2,
PermissionDenied = 3,
Timeout = 4
};
struct Result {
ErrorCode code{ErrorCode::Success};
std::string message;
explicit operator bool() const noexcept {
return code == ErrorCode::Success;
}
};
Result open_file(std::string_view path) {
if (path.empty())
return {ErrorCode::InvalidArgument, "path empty"};
return {ErrorCode::Success, {}};
}
These patterns keep domain meaning in the type system while still mapping cleanly to integers for logging and RPC.
Configuration: ordered LogLevel with explicit comparison
enum class does not provide operator< unless you define it. For ordered severities, either compare underlying values in one place or supply a single comparison function used by the whole module:
enum class LogLevel : std::uint8_t { Trace, Debug, Info, Warning, Error, Fatal };
inline constexpr bool log_at_least(LogLevel msg, LogLevel min) noexcept {
return static_cast<std::underlying_type_t<LogLevel>>(msg) >=
static_cast<std::underlying_type_t<LogLevel>>(min);
}
class Logger {
LogLevel min_{LogLevel::Info};
public:
void set_min(LogLevel l) { min_ = l; }
template <class... Args>
void log(LogLevel level, Args&&... args) {
if (!log_at_least(level, min_)) return;
// emit using std::forward<Args>(args)...
}
};
Avoid writing scattered static_cast logic in every call site; centralize ordering rules next to the enum.
Array indexing with enum class
Use the underlying type as an index into std::array when the enumerators form a dense zero-based set:
enum class Axis : std::size_t { X, Y, Z };
inline constexpr std::array<const char*, 3> axis_name{ "X", "Y", "Z" };
inline const char* name(Axis a) {
return axis_name[static_cast<std::size_t>(a)];
}
If enumerators are not dense, prefer a switch or a std::unordered_map from E to data instead of sparse arrays.
JSON serialization (nlohmann/json)
Many projects register a bidirectional mapping once, then let the library emit and parse strings:
#include <nlohmann/json.hpp>
enum class Priority { Low, Medium, High };
NLOHMANN_JSON_SERIALIZE_ENUM(Priority, {
{ Priority::Low, "low" },
{ Priority::Medium, "medium" },
{ Priority::High, "high" },
})
Downstream code can assign nlohmann::json j = Priority::High and parse with j.get<Priority>(), keeping wire names in a single macro list. For public APIs, version the string values and add tests so renames do not break clients.
Unit tests and golden values
Treat string conversion and parsing as behavior covered by tests: round-trip E -> string -> E, unknown strings, and future reserved numeric values for fixed-width protocols. When using magic_enum, confirm supported compiler and value ranges in CI, because edge cases depend on implementation limits.
Design checklist (quick)
Ask yourself whether the value set is really closed and small enough for an enum class, or whether you are papering over an open-ended config. If the enum is part of a stable ABI, fix the width and write down reserved values. Mixing bit-flags and mutually exclusive states in one type makes switch painful—I split them. And if you need human-facing, locale-specific strings, I keep the wire or log identifier as enum class and put translation keys elsewhere so the two do not fight.
Performance considerations
enum class has no inherent runtime overhead versus unscoped enum when the underlying type matches. switch on small integral backing types lowers to jump tables or compares similarly to plain integers.
Choosing std::uint8_t can save space in structs but may introduce padding depending on neighboring members; profile layout if hot path or cache sensitive.
Compiler support
You are unlikely to trip over enum class or a fixed underlying type on any toolchain you would still ship today—those have been table stakes for a long time. C++20’s using enum needs a reasonably current GCC, Clang, or a recent Visual Studio (think 2019 16.4-ish and up on the Microsoft side). C++23 helpers like std::to_underlying and std::is_scoped_enum want newer compilers; if your CI matrix includes an older MSVC or an LTS distro compiler, gate those uses behind feature tests or provide a tiny fallback. I pin the minimum versions in CI when I rely on the sugar, so nobody’s local “it works” becomes production’s “mystery ifdef.”
Related posts
- Bit manipulation
explicitkeywordconstexprfunctions
Keywords
C++, enum class, scoped enum, strong typing, C++11, type safety, bit flags, state machine