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:

  1. The enumerator names are reachable only through the enumeration's name.
  2. 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.

Nomenclature
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.

Aside
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.

Warning
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.
Danger
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.

Best Practice
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?

Best Practice
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 struct is 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 to std::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 and static_cast<EnumName>(number) coming in. Validate the number before casting it in, because the cast checks nothing. C++23 adds std::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 (int unless you name another).
  • A constexpr helper returning std::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 a switch can write case 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.