Type-Safe Enumerations with enum class
Use enum class for strongly-typed enumerations that prevent implicit conversions.
What Are Scoped Enumerations (Enum Classes)?
A scoped enumeration is an enumeration you define with enum class rather than plain enum. You get the same fixed list of named values, plus two restrictions that the compiler enforces on your behalf:
- The enumerator names are reachable only through the enumeration's name.
- The values never turn into integers unless you ask for it in writing.
Everything else in this lesson is a consequence of those two rules.
Here is one, tracking which breathing gas a dive shop has put into a cylinder:
#include <iostream>
enum class GasMix
{
air,
nitrox,
trimix,
};
int main()
{
GasMix plannedMix{ GasMix::nitrox };
if (plannedMix == GasMix::trimix)
std::cout << "Deep mix: run the helium numbers first." << '\n';
else
std::cout << "Standard mix: log the fill and kit up." << '\n';
return 0;
}
Output:
Standard mix: log the fill and kit up.
The definition is the unscoped syntax with one extra keyword. What changes is every point of use: GasMix::nitrox instead of a bare nitrox.
enum struct is a synonym for enum class and defines exactly the same kind of type. It is non-idiomatic, so write enum class. And despite that keyword, a scoped enumeration is not a class type; "class type" stays reserved for structs, classes, and unions.
Unscoped Versus Scoped, Row by Row
Both forms of enumeration are compared here on the same questions. Read the table first, then the two sections below unpack the right-hand column one rule at a time.
| Question | enum SuitType (unscoped) |
enum class SuitType (scoped) |
|---|---|---|
| Keyword | enum |
enum class, or the synonym enum struct |
| Where the enumerator names are put | the enclosing scope, and also inside SuitType:: |
inside SuitType:: only |
Writing a bare drysuit |
works | error, the name is not visible there |
Writing SuitType::drysuit |
works | the only way |
| Two enumerations in one scope sharing an enumerator name | error, the second is a redeclaration | fine, the names cannot collide |
std::cout << chosenSuit |
prints the integer value | error, there is nothing to print |
chosenSuit == 2 |
compiles | error |
chosenSuit == someOtherEnumValue |
compiles, with a compiler warning | error |
| Getting the integer out | happens on its own | static_cast, always |
| Underlying type if you do not choose one | implementation-defined | int |
SuitType chosen{ 2 } |
only if the enumeration has a fixed underlying type | always allowed since C++17 |
Nothing in that table is about speed or memory. A scoped enumeration compiles down to the same integer a plain enum would; the whole difference is what the compiler agrees to let you write.
Rule One: The Names Live Inside the Type
An unscoped enumeration leaks its enumerators into whatever scope surrounds it. A scoped enumeration keeps them, and the enumeration name works like a namespace in front of them. Access is through the scope resolution operator, ::.
That means two things stop working, and this program is shown to demonstrate them, so it does not compile:
#include <iostream>
enum class GasMix
{
air,
nitrox,
trimix,
};
int main()
{
GasMix plannedMix{ nitrox };
std::cout << plannedMix << '\n';
return 0;
}
The compiler reports (trimmed after each error's first lines):
s.cpp: In function 'int main()':
s.cpp:12:24: error: 'nitrox' was not declared in this scope; did you mean 'GasMix::nitrox'?
12 | GasMix plannedMix{ nitrox };
| ^~~~~~
s.cpp:14:15: error: no match for 'operator<<' (operand types are 'std::ostream' {aka 'std::basic_ostream<char>'} and 'GasMix')
14 | std::cout << plannedMix << '\n';
| ~~~~~~~~~ ^~ ~~~~~~~~~~
The first error is rule one. The second is rule two arriving early: std::cout has no overload for GasMix, and because a scoped enumeration will not quietly become an int, there is no fallback for the compiler to reach for. It then lists all forty-seven operator<< overloads it considered, none of which fit.
The payoff of rule one is that enumerator names are no longer a shared, first-come-first-served resource. Two scoped enumerations sitting side by side in the same scope can each have an open:
#include <iostream>
enum class ValveState
{
shut,
open,
cracked,
};
enum class WaterType
{
open,
cave,
wreck,
};
int main()
{
ValveState tankValve{ ValveState::open };
WaterType site{ WaterType::wreck };
if (tankValve == ValveState::open && site != WaterType::open)
std::cout << "Valve is on, and there is a ceiling over the site." << '\n';
return 0;
}
Output:
Valve is on, and there is a ceiling over the site.
Written as plain enum, that pair does not compile at all: the second open is a redeclaration of the first. With enum class, ValveState::open and WaterType::open are simply different names in different scopes, and no reader has to guess which one a bare open meant.
Because a scoped enumeration already namespaces its own enumerators, wrapping one in a
namespace purely to keep its names tidy is redundant. Wrap it only if the enumeration type itself belongs in that namespace.
Rule Two: The Values Never Become Integers On Their Own
An unscoped enumerator converts to an integer whenever a conversion would make an expression compile. That is convenient right up to the moment it papers over a mistake. The next program is deliberately wrong: it compares a gas mix against a wetsuit, which means nothing, and it builds and runs anyway.
#include <iostream>
enum GasMix
{
air,
nitrox,
trimix,
};
enum SuitType
{
shorty,
wetsuit,
drysuit,
};
int main()
{
GasMix plannedMix{ air };
SuitType chosenSuit{ shorty };
if (plannedMix == chosenSuit)
std::cout << "The mix and the suit came out the same." << '\n';
else
std::cout << "The mix and the suit came out different." << '\n';
return 0;
}
Compiler output and program output:
s.cpp: In function 'int main()':
s.cpp:22:20: warning: comparison between 'enum GasMix' and 'enum SuitType' [-Wenum-compare]
22 | if (plannedMix == chosenSuit)
| ~~~~~~~~~~~^~~~~~~~~~~~~
The mix and the suit came out the same.
Follow what the compiler did. It has no way to compare a GasMix with a SuitType directly, so it converted both operands to integers, which is a comparison it does know how to make. air and shorty are both the first enumerator of their list, so both are 0, so the two "came out the same". The diagnostic is a warning, not an error: the executable was still produced and still ran.
A warning is advice, and a build that ignores warnings will ship this. The unscoped enumeration cannot express "these two types are not comparable", so the best any compiler can do is mention it on the way past.
Now change nothing except the two keywords. This version does not compile, which is the entire point of showing it:
#include <iostream>
enum class GasMix
{
air,
nitrox,
trimix,
};
enum class SuitType
{
shorty,
wetsuit,
drysuit,
};
int main()
{
GasMix plannedMix{ GasMix::air };
SuitType chosenSuit{ SuitType::shorty };
if (plannedMix == chosenSuit)
std::cout << "The mix and the suit came out the same." << '\n';
return 0;
}
The compiler reports (candidate list trimmed):
s.cpp: In function 'int main()':
s.cpp:22:20: error: no match for 'operator==' (operand types are 'GasMix' and 'SuitType')
22 | if (plannedMix == chosenSuit)
| ~~~~~~~~~~ ^~ ~~~~~~~~~~
| | |
| GasMix SuitType
• there are 2 candidates
• candidate 1: 'operator==(SuitType, SuitType)' (built-in)
• no known conversion for argument 1 from 'GasMix' to 'SuitType'
• candidate 2: 'operator==(GasMix, GasMix)' (built-in)
• no known conversion for argument 2 from 'SuitType' to 'GasMix'
The candidate list is the whole story. Only two comparisons exist, GasMix against GasMix and SuitType against SuitType, and neither operand can travel to the other type. There is no integer detour to fall back on, so the mistake is a build failure instead of a runtime coin flip.
The same rule blocks plannedMix == 1, plannedMix + 1, and if (plannedMix). Comparing a scoped enumerator with another value of its own type is untouched, which is what the very first program in this lesson was doing.
Converting On Purpose
The rule is about implicit conversions. When you genuinely want the number, ask for it with static_cast, in either direction:
#include <iostream>
enum class GasMix
{
air,
nitrox,
trimix,
};
int main()
{
GasMix plannedMix{ GasMix::trimix };
std::cout << "Mix code stored in the log: " << static_cast<int>(plannedMix) << '\n';
int codeFromLog{ 1 };
GasMix restoredMix{ static_cast<GasMix>(codeFromLog) };
if (restoredMix == GasMix::nitrox)
std::cout << "Code 1 came back as nitrox." << '\n';
GasMix formMix{ 2 };
if (formMix == GasMix::trimix)
std::cout << "Code 2 came back as trimix." << '\n';
return 0;
}
Output:
Mix code stored in the log: 2
Code 1 came back as nitrox.
Code 2 came back as trimix.
Three conversions worth separating:
static_cast<int>(plannedMix)takes the enumeration out to an integer. This is the one you reach for to print a value or store it.static_cast<GasMix>(codeFromLog)brings a runtime integer back in, which is how you turn a number typed by a user or read from a file into an enumeration value.GasMix formMix{ 2 }is list initialization straight from an integral value, allowed since C++17 with no cast at all. A scoped enumeration always has a fixed underlying type, so this always works; an unscoped enumeration has to declare a base before it earns the same privilege.
Casting an integer in performs no validation.
static_cast<GasMix>(9) is accepted and hands you a GasMix that equals none of the three enumerators, so every if and every case written against them quietly misses. Range-check the integer before the cast, never after.
One Helper Beats Twenty Casts
Casts are loud on purpose, but a codebase that converts constantly ends up more static_cast than logic. Wrap the conversion once, in a function that follows the enumeration's underlying type rather than hardcoding int:
#include <iostream>
#include <type_traits>
enum class GasMix : short
{
air,
nitrox,
trimix,
};
constexpr std::underlying_type_t<GasMix> mixCode(GasMix mix)
{
return static_cast<std::underlying_type_t<GasMix>>(mix);
}
int main()
{
std::cout << "air=" << mixCode(GasMix::air)
<< " nitrox=" << mixCode(GasMix::nitrox)
<< " trimix=" << mixCode(GasMix::trimix) << '\n';
return 0;
}
Output:
air=0 nitrox=1 trimix=2
std::underlying_type_t lives in <type_traits> and names whatever integer type sits beneath the enumeration, here short because the definition asked for it. The helper is constexpr, so it costs nothing at runtime, and it keeps the conversion explicit: you still have to write mixCode(...), which is the point. C++23 adds std::to_underlying() in <utility>, which is this exact helper written for you; until you are on C++23, static_cast or a small wrapper like this one is the way.
Dropping the Prefix With using enum (C++20)
The EnumName:: prefix is the price of rule one, and inside a switch over a single enumeration that price is paid on every line for no information gained. C++20's using enum declaration imports an enumeration's enumerators into the current scope, so you can drop the prefix exactly where it is redundant:
#include <iostream>
#include <string_view>
enum class DivePhase
{
descent,
bottom,
ascent,
safetyStop,
};
constexpr std::string_view phaseLabel(DivePhase phase)
{
using enum DivePhase;
switch (phase)
{
case descent: return "on the way down";
case bottom: return "working the bottom time";
case ascent: return "on the way up";
case safetyStop: return "hanging on the safety stop";
}
return "off the profile";
}
int main()
{
DivePhase phase{ DivePhase::safetyStop };
std::cout << "The diver is " << phaseLabel(phase) << '\n';
return 0;
}
Output:
The diver is hanging on the safety stop
Two details are easy to miss. The using enum DivePhase; sits inside phaseLabel(), so the unprefixed names exist only for that function body; main() below still writes DivePhase::safetyStop. And this is a scoping convenience only: it changes nothing about rule two, so phase still refuses to become an integer inside that function.
Keep
using enum at the narrowest scope that removes the repetition, usually one function body. At namespace scope it re-creates the name pollution that enum class was chosen to avoid.
Which One Should You Reach For?
Default to
enum class. Fall back to a plain enum only when you have a concrete reason, and treat that reason as something you can name out loud.
The reasons that hold up are narrow. Plain enumerations remain useful when the values really are meant to be integers, for example flag bits combined with | or values used to index into something, because a scoped enumeration turns each of those uses into a cast. They are also unobjectionable when the enumeration is already tucked inside a small scope where its names cannot collide with anything. Outside those cases, the cost of enum class is typing a prefix and the benefit is that a whole family of nonsense expressions stops compiling.
Summary
- A scoped enumeration is written
enum class Name { ... };.enum structis an identical but unidiomatic synonym. - Rule one: enumerators are placed in the enumeration's own scope region, so you write
GasMix::nitrox. Two scoped enumerations in the same scope may reuse an enumerator name without conflict. - Rule two: no implicit conversion to an integral type. Comparing against an
int, against a different enumeration, streaming tostd::cout, or arithmetic all fail to compile. Comparing against the same enumeration type is fine. - Together the rules turn a class of silent bugs into build errors. The unscoped version of the same mistake converts both sides to integers and compares them, and a compiler can only warn about it.
- Convert deliberately with
static_cast<int>(value)going out andstatic_cast<EnumName>(number)coming in. Validate the number before casting it in, because the cast checks nothing. C++23 addsstd::to_underlying()for the outbound direction. - Since C++17,
EnumName value{ 2 }list-initializes a scoped enumeration from an integral value with no cast, because scoped enumerations always have a fixed underlying type (intunless you name another). - A
constexprhelper returningstd::underlying_type_t<EnumName>keeps repeated conversions readable without weakening the rule. using enum EnumName;(C++20) imports the enumerators into the current scope so aswitchcan writecase descent:. Keep it function-local.- Prefer scoped enumerations by default; choose an unscoped one only when the integer conversion is the feature you actually want.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Type-Safe Enumerations with enum class - Quiz
Test your understanding of the lesson.
Practice Exercises
Scoped Enumerations (enum class)
Create type-safe enumerations using enum class. Practice defining, using, and converting scoped enumerators.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!