The Compiler Always Knew Your Types: How C++26 Reflection Changes the Game
Source: isocpp
In March 2026, Bernard Teo’s overview of C++26 reflection laid out the different approaches reflection can take and where C++26 lands among them. It is a worthwhile read, originally published earlier this month. The short version: compile-time reflection made it into C++26 via P2996, Clang and GCC both have experimental implementations, and C++ programmers are about to stop writing a whole category of brittle workarounds.
But the more interesting story is not the feature itself. It is what the feature replaces, why it took twenty years to standardize, and what it means to have reflection that runs entirely at compile time rather than at runtime.
The Information Was Always There
Every C++ compiler already knows your struct layout, your enum values, your member names, and your base classes. It uses that information to generate code. The problem is it never exposed that knowledge to the programmer in a principled way. The programmer was left to reconstruct it through templates, macros, and compiler-specific hacks.
The magic_enum library is a good example of the lengths people have gone to. It works by instantiating a function template for every possible enum value in a range, then parsing the compiler’s __PRETTY_FUNCTION__ or __FUNCSIG__ string to extract the name. It is remarkable engineering, and it is entirely unnecessary once the compiler can just tell you the name directly. It also has a default range of -128 to 128, which means any enum with values outside that range silently breaks.
Boost.PFR does something similar for structs: it uses aggregate initialization rules and structured binding tricks to iterate over fields at compile time without requiring any macro annotations. It works for aggregates, cannot give you field names, and relies on behavior that is undefined-adjacent enough that each new compiler version is a small adventure.
X-macros, the oldest trick in the book, require you to define your enum twice:
#define COLORS(X) X(Red) X(Green) X(Blue)
#define MAKE_ENUM(x) x,
#define MAKE_STRING(x) #x,
enum Color { COLORS(MAKE_ENUM) };
const char* names[] = { COLORS(MAKE_STRING) };
This works. It has worked since C89. It is also completely opaque to tooling, produces terrible error messages, and has to be maintained manually every time the enum changes.
All of these patterns exist because C++ programmers needed to solve real problems: serialization, logging, ORM mapping, enum-to-string conversion. The solutions were clever. They were also workarounds for the absence of something the compiler already had.
What P2996 Gives You
P2996 introduces a single new operator, ^, that reflects any named entity into a value of type std::meta::info. That value can be interrogated at compile time using functions in the new std::meta namespace, and converted back into usable language entities via the [: r :] splice syntax.
The enum-to-string case becomes:
template<typename E>
requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value) {
template for (constexpr auto e : std::meta::enumerators_of(^E)) {
if (value == [: e :]) {
return std::meta::identifier_of(e);
}
}
return "<unknown>";
}
static_assert(enum_to_string(Color::Green) == "Green");
This handles any enum regardless of value range, works on enums defined in third-party headers, and requires no macro annotations. The template for loop is the expansion statement from P1306 (still working its way through the process), but the reflection itself is standard C++26.
Field iteration for serialization looks similar:
template<typename T>
void to_json(const T& obj, nlohmann::json& j) {
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^T)) {
j[std::string(std::meta::identifier_of(m))] = obj.[: m :];
}
}
The member access obj.[: m :] splices the reflected member back into a direct field access. The compiler sees through it entirely; the generated machine code is the same as writing obj.x, obj.y, obj.z by hand.
Why It Took Twenty Years
The first reflection proposals for C++ date to around 2004. The problem was not lack of motivation, it was finding a design that fit the language.
Early proposals like P0385 from 2016 used a reflexpr() operator that returned complex nested template types rather than a single opaque value. Querying the first data member of a class looked like:
using refl = reflexpr(MyClass);
using members = meta::get_data_members_t<refl>;
using first = meta::get_element_t<0, members>;
This is the template metaprogramming style C++ programmers were already using for type traits. It is composable in principle, but the ergonomics are poor: compile errors produce multi-screen template instantiation traces, and writing even simple logic requires significant boilerplate.
The breakthrough came with P1240 in 2019, which introduced the ^ and [: :] syntax. Instead of returning a new type for each reflected entity, reflection produces a single value type (std::meta::info) that can be passed to library functions. The compiler intrinsics are hidden behind a normal-looking API. Error messages appear at the consteval call site rather than inside recursive template instantiations. P2996 built on that foundation and carried the design through to a final vote at the Wroclaw WG21 meeting in November 2024.
The twenty-year gap was not wasted time. The failed proposals accumulated understanding of what the design space looked like and which approaches had fundamental ergonomic problems.
The Compile-Time Flavour and Why It Fits C++
Runtime reflection, as practiced in Java and C#, is a different beast. java.lang.reflect.Method.invoke() allocates, performs security checks, boxes primitive arguments, and dispatches dynamically. It costs roughly 100 times as much as a direct call. C#‘s PropertyInfo.GetValue() is in the same range. These are appropriate tradeoffs for languages with managed runtimes, garbage collectors, and assembly loading at runtime, but they represent a fundamental change in the cost model of an operation.
C++ cannot accept that cost model, and P2996 does not ask it to. Every std::meta function is consteval, meaning it can only run during compilation. The restriction is enforced by the type system: calling std::meta::nonstatic_data_members_of(^T) in a non-consteval context is a compile error.
At runtime, nothing remains. The reflection calls are erased. A loop over struct fields at compile time compiles to exactly the same code as writing out each field access by hand. There are no type metadata tables added to the binary unless you explicitly store a string_view from identifier_of() somewhere, and then it is just a string literal like any other.
This is the only flavour that makes sense for C++. The alternative would be to add a parallel runtime metadata system (similar to RTTI but more extensive), and C++ programmers have been opting out of RTTI since the 1990s for exactly the cost reasons that would apply.
What the API Actually Looks Like
The std::meta namespace reads like a well-typed introspection API:
// Predicates
std::meta::is_enum(^T) // bool
std::meta::is_class(^T) // bool
std::meta::is_public(member_r) // bool
std::meta::is_static_member(r) // bool
// Navigation
std::meta::type_of(member_r) // info — type of a data member
std::meta::parent_of(r) // info — enclosing class or namespace
std::meta::underlying_type_of(^E)// info — enum's underlying type
// Enumeration
std::meta::nonstatic_data_members_of(^T) // vector<info>
std::meta::enumerators_of(^E) // vector<info>
std::meta::bases_of(^T) // vector<info>
std::meta::parameters_of(^func) // vector<info>
// Names and layout
std::meta::identifier_of(r) // string_view
std::meta::offset_of(member_r) // size_t
std::meta::size_of(^T) // size_t
std::meta::alignment_of(^T) // size_t
Code injection is also possible via data_member_spec and define_class, which allow generating new struct members programmatically. The canonical example from the proposal is transforming an array-of-structures layout into a structure-of-arrays layout by iterating a type’s fields and emitting one std::vector<member_type> for each.
State of Implementations
Bloomberg maintains a Clang fork implementing P2996 that tracks the proposal closely. It covers most of the accepted feature set and is available on Compiler Explorer under the experimental C++26 Clang builds, letting you run examples in a browser today. GCC has experimental work in personal forks but nothing merged into mainline yet. MSVC has not announced an implementation timeline.
The companion paper for expansion statements, P1306, is still moving through the committee. The workaround in the meantime is using fold expressions or recursive template helpers rather than template for, which is more verbose but achieves the same result for most cases.
What This Changes in Practice
For day-to-day C++ work, the biggest immediate impact will be enum utilities and serialization. Libraries like magic_enum and the various struct-reflection hacks will become unnecessary. Code that currently requires annotating structs with macros, maintaining parallel definitions for enum names, or depending on undefined behavior in Boost.PFR will be expressible cleanly in standard C++.
For library authors, the impact is larger. Framework-level serialization, ORM bindings, property systems, and reflection-based testing utilities become first-class standard features rather than elaborate macro systems. The ergonomics of writing generic code that operates over struct fields, enum values, or function signatures change substantially.
The C++26 standardization process is on track for publication in mid-2026. Given that Clang already has working experimental builds, production-quality compiler support should follow within a year or two of publication. Twenty years is a long time to wait for a feature. The result is worth it.