cpp26-reflection · part 05

Goodbye magic_enum: enum reflection done right

· english · audience: working-cpp · discuss

magic_enum is one of the cleverest tricks in modern C++. It gives you enum → string and back by extracting names from __PRETTY_FUNCTION__ in a constexpr loop over a fixed integer range. It works well — within its limits. Those limits:

  • Range-bound: default [-128, 128]. Outside that, it silently returns nothing. Knob MAGIC_ENUM_RANGE_MIN/MAX widens — at compile-time cost.
  • Compiler-specific: it parses __PRETTY_FUNCTION__, whose format is GCC / Clang / MSVC each different. Format changes break it.
  • Slow to compile: instantiates N specialisations where N is the range size.
  • Can’t deal with flags enums that combine values (1 | 2 | 4 isn’t in the range).

P2996 collapses this. enumerators_of(^^E) returns a list of every declared enumerator, directly from the compiler. No range, no format parsing, no knob.

The core: 20 lines

#include <experimental/meta>
#include <optional>
#include <string_view>

namespace renum {

template <typename E>
consteval auto enumerators_of_static() {
    return std::define_static_array(std::meta::enumerators_of(^^E));
}

template <typename E>
constexpr std::string_view to_string(E value) {
    template for (constexpr auto e : enumerators_of_static<E>()) {
        if ([: e :] == value) return std::meta::identifier_of(e);
    }
    return "<unknown>";
}

template <typename E>
constexpr std::optional<E> from_string(std::string_view name) {
    template for (constexpr auto e : enumerators_of_static<E>()) {
        if (std::meta::identifier_of(e) == name) return E{[: e :]};
    }
    return std::nullopt;
}

}  // namespace renum

Three moves:

  1. enumerators_of(^^E)std::vector<std::meta::info>, one info per enumerator, declared order.
  2. template for (constexpr auto e : ...) — iteration at compile time. e is a reflection of the individual enumerator.
  3. [: e :] == value — splice the enumerator back into code. In expression context, [: e :] is a value of type E, equal to that enumerator’s constant.

The compiler unrolls the loop into a short-circuited if/else chain. to_string(Color::green) becomes return "green" after optimization — as efficient as a hand-written switch.

Usage

enum class Color { red, green, blue };

static_assert(renum::to_string(Color::green) == "green");
static_assert(renum::from_string<Color>("blue") == Color::blue);
static_assert(renum::from_string<Color>("purple") == std::nullopt);

Everything is constexpr. You can use these results in static asserts, template parameters, anywhere.

What about flags enums?

Flags enums use bitwise combinations — Permission::read | Permission::write = 3, which is not itself a declared enumerator. Reflection handles this because you write the decomposition yourself, you’re not bound by what enumerators_of returned:

enum class Permission : unsigned { read = 1, write = 2, exec = 4 };

template <typename E>
constexpr std::string to_flags_string(E value) {
    using U = std::underlying_type_t<E>;
    std::string out;
    template for (constexpr auto e : enumerators_of_static<E>()) {
        if ((static_cast<U>(value) & static_cast<U>([: e :])) != 0) {
            if (!out.empty()) out += '|';
            out += std::meta::identifier_of(e);
        }
    }
    return out.empty() ? "0" : out;
}

// to_flags_string(Permission::read | Permission::write) == "read|write"

magic_enum::enum_flags_name does this too — but only up to the range limit.

The inverse — string back to flags — has two equivalent shapes. First the imperative form, which is the mental model:

// What we want:
template <typename E>
constexpr std::optional<E> from_flags_string(std::string_view s) {
    using U = std::underlying_type_t<E>;
    if (s.empty()) return std::nullopt;
    U combined{};
    for (auto part : std::views::split(s, '|')) {
        std::string_view token(part.begin(), part.end());
        auto match = from_string<E>(token);
        if (!match) return std::nullopt;       // typo -> fail closed
        combined |= static_cast<U>(*match);
    }
    return E{combined};
}

Now the same logic as a ranges chain: std::ranges::fold_left (C++23) accumulates an std::optional<U>; the binary op uses optional’s monadic .and_then to gate further work on prior success and .transform to combine the matched value, so the fail-closed step falls out of the monad rather than explicit early returns. The outer .transform puts the final U back into E:

// Same logic, ranges chain:
template <typename E>
constexpr std::optional<E> from_flags_string(std::string_view s) {
    using U = std::underlying_type_t<E>;
    if (s.empty()) return std::nullopt;
    return std::ranges::fold_left(
        std::views::split(s, '|'),
        std::optional<U>{U{}},
        [](std::optional<U> acc, auto part) -> std::optional<U> {
            return acc.and_then([&](U a) -> std::optional<U> {
                return from_string<E>(std::string_view(part.begin(), part.end()))
                    .transform([a](E e) { return a | static_cast<U>(e); });
            });
        }
    ).transform([](U v) { return E{v}; });
}

// from_flags_string<Permission>("read|write") == Permission{3}
// from_flags_string<Permission>("read|writ")  == std::nullopt   // typo rejected

A typo in a config file or CLI flag returns std::nullopt rather than a silently-zero mask or a partial match: any token that fails to look up returns nullopt from the inner from_string, the inner .transform short-circuits, the outer .and_then propagates it, and every subsequent fold step is a no-op. The repo example keeps both forms side by side in a single comment block so anyone unfamiliar with the C++23 ranges algorithms can read across.

Caveat for production use: if your enum declares a combined enumerator (e.g. Permission::all = 7), the naive to_flags_string above emits both the combined name AND its constituents ("read|write|exec|all"). Production-grade flag-string libraries (magic_enum::enum_flags_name, boost) short-circuit on exact match before falling back to bit-decomposition. The 30-line version is fine for teaching and for enums whose enumerators are pure single-bit; tighten before deploying for enums with combined names.

What you lose vs. magic_enum

Honestly, not much that matters:

  • Single-statement usage at any scope. magic_enum::enum_name(x) is usable anywhere without setup. Our version requires you to have included the header. Same.
  • Automatic detection of scoped vs unscoped enums. Both work identically in our code.
  • Works on enum (unscoped). Works the same.

What you gain

  • Unbounded range. Any enumerator value, including ones larger than INT_MAX.
  • No compile-time knob. Whatever you declared, reflection sees. No MAGIC_ENUM_RANGE_MAX.
  • Faster compiles. magic_enum at high range instantiates thousands of template specialisations. We instantiate one per real enumerator.
  • No compiler-specific parsing. Works the same on clang-p2996 / gcc / MSVC — wherever P2996 ships.
  • First-class IDE support. Your debugger and IDE navigate into reflection library calls like any other code. __PRETTY_FUNCTION__ parsing is invisible to both.

Adding std::formatter

A one-liner makes enum values {}-printable in std::format:

template <typename E>
    requires std::is_enum_v<E>
struct std::formatter<E> : std::formatter<std::string_view> {
    auto format(E value, std::format_context& ctx) const {
        return std::formatter<std::string_view>::format(
            renum::to_string(value), ctx);
    }
};

std::println("{}", Color::red);  // red

The full demo

#include <experimental/meta>
#include <print>

enum class Color { red, green, blue };
enum class Permission : unsigned { read = 1, write = 2, exec = 4 };

int main() {
    std::println("{}", renum::to_string(Color::green));   // green
    std::println("{}", renum::from_string<Color>("blue")
                          .transform([](auto c){ return renum::to_string(c); })
                          .value_or("<none>"));           // blue
    std::println("{}", renum::to_flags_string(
                          Permission::read | Permission::exec));  // read|exec
}

What’s next