For ten years, C++ developers have been solving the same problem with different workarounds. You want a generic to_string for enums, so you reach for a third-party library, or write a switch statement, or paste in an X_MACRO pattern that forces every new enumerator to be listed twice. You want struct serialization without per-type boilerplate, so you decorate your struct with NLOHMANN_DEFINE_TYPE_INTRUSIVE, or define a tie() method, or generate code from a separate schema. Every solution circles around the same missing capability: the compiler knows the names and types of your struct fields at compile time, but there has been no way to access that knowledge in library code.
C++26 fixes this. The P2996 proposal, accepted into the standard, gives library authors the ability to enumerate struct members, query enum values, inspect function signatures, and generate new code from that information, all at compile time, with no runtime overhead. A recent retrospective by Bernard Teo on ISOCpp, originally published in early March 2026, surveys how different languages have approached this problem and where C++ now sits in that landscape. Rather than retrace that survey, this post focuses on what makes P2996’s design work where earlier proposals failed, and what the practical impact looks like in real code.
The Three Pieces
The mechanism in P2996 rests on three syntactic additions that each do one thing cleanly.
The first is the ^ operator, a compile-time unary operator that takes a C++ entity and produces a std::meta::info value representing it:
constexpr std::meta::info r_int = ^int;
constexpr std::meta::info r_point = ^Point;
constexpr std::meta::info r_ns = ^std;
std::meta::info is an opaque handle, usable only in consteval contexts. It does not exist at runtime.
The second piece is the std::meta namespace, which contains consteval functions that query these handles:
std::meta::nonstatic_data_members_of(^T) // range of meta::info for each field
std::meta::enumerators_of(^E) // range of meta::info for each enumerator
std::meta::identifier_of(r) // string_view of the name
std::meta::type_of(r) // meta::info for the member's type
std::meta::is_public(r) // access specifier query
These are not macros or special syntax. They compose naturally because they are ordinary consteval functions.
The third piece is the splice operator [: r :], which converts a meta::info back into a syntactic entity:
Point p{1, 2};
constexpr auto rx = ^Point::x;
p.[:rx:] = 10; // equivalent to: p.x = 10
[:^double:] d = 3.14; // equivalent to: double d = 3.14
The linchpin that makes field iteration practical is template for, an expansion statement that iterates at compile time over a heterogeneous range of reflections:
template for (constexpr auto member : std::meta::nonstatic_data_members_of(^T)) {
// instantiated once per field; member is a compile-time constant
}
What This Replaces
The practical impact becomes clear when you compare before and after for two common tasks.
Enum to string. The standard pre-C++26 options are a hand-written switch statement, the X_MACRO pattern where you define enum values in a macro and expand it twice, or magic_enum, which works by parsing compiler-specific __PRETTY_FUNCTION__ strings. magic_enum is clever but fragile, compiler-specific, and limited to enums with values inside a fixed range.
With P2996:
template <typename E> requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value) {
template for (constexpr auto enumerator : std::meta::enumerators_of(^E)) {
if (value == [:enumerator:])
return std::meta::identifier_of(enumerator);
}
return "<unknown>";
}
enum class Direction { North, South, East, West };
static_assert(enum_to_string(Direction::East) == "East");
No compiler-specific hacks. No preprocessing. The function works for any enum, including those you did not write and did not annotate.
Struct serialization. The standard approach today uses library macros (NLOHMANN_DEFINE_TYPE_INTRUSIVE for nlohmann/json, BOOST_HANA_DEFINE_STRUCT for Boost.Hana) or requires specializing a template for each type. With P2996, a generic serializer becomes a single template function:
template <typename T>
nlohmann::json to_json(const T& obj) {
nlohmann::json j;
template for (constexpr auto member : std::meta::nonstatic_data_members_of(^T)) {
j[std::meta::identifier_of(member)] = obj.[:member:];
}
return j;
}
This works for any struct, including types from third-party libraries, without requiring that the type author add any annotations or opt in.
How This Differs from Zig and Rust
Zig’s comptime is the closest philosophical comparison. Zig allows arbitrary computation at compile time using the same language syntax as runtime code, and @typeInfo(T) returns a union describing the type structure. The equivalent of the serializer above in Zig uses inline for over std.meta.fields(T), which returns field names, types, and alignment.
The design philosophies diverge on one important axis. Zig’s comptime operates on a description of types: field names, types, alignment. It is a well-specified version of what boost::pfr does in C++17. C++26 reflection operates on the full semantic model: fully resolved types with all template arguments substituted, access specifiers, member offsets, base classes, linkage, and whether something is constexpr. You can ask whether a member is public, what template arguments a specialization was instantiated with, or what the return type of a function is. Zig’s simplicity has genuine advantages in ergonomics, but P2996 is richer for the use cases that depend on the full type system.
Rust’s approach is more distant. Rust has no runtime reflection by design, and the reflection-adjacent feature is the derive macro system: #[derive(Serialize)] triggers a procedural macro that receives the token stream of your struct definition and generates trait implementations. The key limitation is that proc macros operate on the syntax tree, not on the semantic model. They cannot check whether a field type implements a trait without explicit bounds, or compute the offset of a field. The author of the Serde library had to write syn and quote as full crates just to parse and emit Rust syntax inside a macro. C++26 reflection is a library in the conventional sense: no separate compilation step, no sandboxed subprocess, no token stream manipulation.
The Zero-Runtime-Cost Distinction
When Java developers say reflection, they mean Field.get(obj) at runtime, which involves security checks, type erasure, boxing of primitives, and hash table lookups in the JVM. C#‘s System.Reflection is similar. These systems carry the full type structure in memory at runtime, which enables features like deserializing into unknown types, but at the cost of overhead and obscured control flow.
C++26 reflection is strictly compile-time. The std::meta::info type cannot exist at runtime. After compilation, no trace of the reflection machinery remains in the binary. A serializer written with P2996 compiles down to the same machine code as one written by hand, field by field. The optimizer sees ordinary member accesses and can apply all the usual transformations, including vectorization and alias analysis that a function-call boundary would otherwise prevent.
This distinction explains both the feature’s value and why it took so long to standardize. You get the expressiveness of Java-style reflection without any of the runtime cost, but achieving that requires the compiler to evaluate complex constexpr computations during template instantiation, which is a significant implementation burden.
Why It Took Fifteen Years
Early proposals like the reflexpr() family (P0194 through P0712, 2016—2018) proposed returning metadata as types via template metaprogramming. The API required writing reflect::get_name<reflexpr(T)>::value to get a name, which is the same TMP style that made Boost.MPL exhausting. Implementation cost was uncertain and the committee was not convinced the ergonomics justified adoption.
P1240 in 2019 changed the design significantly, introducing the ^ reflect operator and [: :] splice syntax. Rather than returning type-level metadata, it proposed an opaque value std::meta::info queried through consteval functions, which are far easier to compose. Working implementations in both Clang (maintained in the Bloomberg Clang fork) and EDG gave the committee concrete code to evaluate and allowed edge cases to surface before ratification. P2996 built directly on P1240’s foundation.
The feature is available experimentally in Clang today. You can try the examples in this post on Compiler Explorer by selecting the experimental P2996 Clang build. The enum-to-string and struct serialization examples compile and behave as shown. The library ecosystem has not yet caught up, but it is easy to see where magic_enum and macro-heavy serialization libraries will be in three years once compilers ship C++26 support.