Goodbye magic_enum: enum reflection done right
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. KnobMAGIC_ENUM_RANGE_MIN/MAXwidens — 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 | 4isn’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:
enumerators_of(^^E)—std::vector<std::meta::info>, oneinfoper enumerator, declared order.template for (constexpr auto e : ...)— iteration at compile time.eis a reflection of the individual enumerator.[: e :] == value— splice the enumerator back into code. In expression context,[: e :]is a value of typeE, 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_enumat 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
- Post 6 — Auto
std::formatter<T>for any aggregate — the same pattern, applied to struct printing. - Post 7 — Derive equality, hashing, ordering — from struct shape.