· 6 min read ·

C++26 Reflection Escapes the TMP Trap by Treating Metadata as Data

Source: isocpp

The implementation work is largely complete. Bloomberg’s experimental Clang fork has been running examples on Compiler Explorer for over a year, and the proposal, P2996, was accepted into the C++26 working draft at the WG21 Wrocław meeting in November 2024. Bernard Teo’s retrospective on isocpp.org examines the different styles of reflection you encounter in C++26, which is a good occasion to dig into the specific design decision that separates P2996 from everything that came before it: reflection metadata is a value, not a type.

The TMP Era

For about a decade, the dominant reflection proposal was reflexpr, evolving through P0194, P0953, and eventually P1240. The basic model transformed reflexpr(T) into a type you could query through type traits:

// reflexpr — never adopted
using meta_T = reflexpr(MyStruct);
using members = std::reflect::get_data_members_t<meta_T>;
// iterate with std::reflect::get_element_t<I, members>

Every operation produced a type or a type list. To iterate over members, you needed variadic packs, fold expressions, std::integer_sequence, and type list utilities from boost::mp11 or boost::hana. Every piece of reflection code lived in the TMP sublanguage rather than ordinary C++. Compiler errors were opaque, compile times were punishing, and the only people who could use it productively were already fluent in template metaprogramming.

P1240 introduced the ^ and [: :] operator syntax that P2996 later inherited, and it was a partial step toward value-based design. It still mixed type-level and value-level manipulation, left enough design questions unresolved, and stalled at the Prague 2020 WG21 meeting.

What P2996 Changed

P2996 replaced the type-based layer entirely. Reflected entities are values of a single opaque type, std::meta::info. The type is scalar, trivially copyable, and only meaningful during constant evaluation: it exists inside consteval functions and nowhere else. Within that context, it behaves like any other C++ value, storable in arrays, passable to functions, comparable with ==.

constexpr std::meta::info r  = ^int;          // reflects the type int
constexpr std::meta::info r2 = ^MyStruct;     // reflects a struct
constexpr std::meta::info r3 = ^std::printf;  // reflects a function

The ^ operator is the reflect operator. The query functions in std::meta are all consteval and work on these values with ordinary function call syntax:

consteval void inspect_type(std::meta::info t) {
    for (auto mem : std::meta::nonstatic_data_members_of(t)) {
        std::string_view name    = std::meta::name_of(mem);
        std::meta::info mem_type = std::meta::type_of(mem);
    }
}

The for loop here is a plain C++ range-based for loop. nonstatic_data_members_of returns a std::vector<std::meta::info>, usable anywhere a range is accepted. You can filter it with std::ranges::filter, sort it, count it, or pass a subset to another function. The loop body is not a template instantiation and requires no TMP machinery. If you know C++17, you can write this code.

The companion paper P1306 adds template for, which instantiates its body once per element in a compile-time range, enabling per-member code generation:

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::name_of(e);
    }
    return "<unknown>";
}

enum Color { Red, Green, Blue };
static_assert(enum_to_string(Green) == "Green");

The [: e :] syntax is the splice operator. It materializes a std::meta::info value back into code as the entity it represents: here, an enumerator value in an expression context. The result compiles to the same machine code as a hand-written switch statement, with no runtime overhead.

The Splice System in Practice

Splicing adapts to context. The same [: r :] syntax produces a type, an expression, or a template argument depending on where it appears:

constexpr auto r = ^int;
[: r :] x = 42;          // x has type int
std::vector<[: r :]> v;  // std::vector<int>

struct Point { int x, y; };
constexpr auto mems = std::meta::nonstatic_data_members_of(^Point);
Point p{1, 2};
p.[: mems[0] :] = 10;    // p.x = 10

Member access through a spliced reflection is fully resolved at compile time and compiles identically to direct member access. The generic serialization pattern that motivates the whole proposal follows directly:

template <typename T>
std::string to_json(const T& val) {
    std::string out = "{";
    bool first = true;
    template for (constexpr auto mem : std::meta::nonstatic_data_members_of(^T)) {
        if (!first) out += ",";
        out += "\"" + std::string(std::meta::name_of(mem)) + "\":";
        out += to_json(val.[: mem :]);
        first = false;
    }
    return out + "}";
}

This is genuinely generic. It works for any aggregate struct without annotation, without macros, and without an external code generator.

What Other Languages Have

Rust’s procedural macros deliver the practical result C++ developers have been watching from a distance: the serde crate can derive serialization for any struct from a single #[derive(Serialize)] attribute. But proc macros operate on token streams, before type checking. They receive the syntactic representation of a declaration and emit new tokens; they cannot directly ask what the type of a field is in a semantic sense. The model is structural reflection over syntax, not semantic reflection over a type-checked program.

C++26 reflection operates after type checking. std::meta::type_of(mem) gives you the actual type of a data member. std::meta::is_public(mem) gives you access level. std::meta::offset_of(mem) gives you the byte offset. You can write a serializer that dispatches on actual field types, respects access control, and handles the full semantic structure of the class, within a single generic function, without a separate compilation step or a build-time code generation tool.

D’s __traits system is the closest prior art among production languages, and P2996 acknowledges it. D has had compile-time member iteration for years with a conceptually similar model. The structural difference is that D’s __traits is a fixed set of built-in compiler operations, while P2996 builds everything on a single uniform type and a std::meta library that composes like any other C++ library. You can write your own consteval functions that accept and return std::meta::info values, build abstractions on top of them, and combine them with standard algorithms.

Practical Targets

The immediate impact is on the ecosystem of workarounds that currently substitutes for reflection. magic_enum, which implements enum-to-string using compiler-specific __PRETTY_FUNCTION__ hacks and substantial template machinery, becomes unnecessary. nlohmann::json’s NLOHMANN_DEFINE_TYPE_INTRUSIVE macro, BOOST_DESCRIBE_STRUCT, and equivalent annotation macros in other serialization frameworks become unnecessary. ORM column mapping, configuration parsers, protocol buffer codecs that currently require protoc as a separate build step: all of these become writeable as clean library code.

The broader effect is on how libraries expose their functionality. A library that currently requires a macro protocol, because there was no other way to generate per-type code at compile time, can expose a pure C++ API instead. Users get a function call or a concept constraint rather than a macro with ordering requirements and scope restrictions.

What Is Still Missing

Code injection, the ability to synthesize new member declarations inside a class definition at compile time, requires a companion paper, P3294. P2996 alone lets you read and use what exists in a type; P3294 lets you write new declarations programmatically. P3294 is targeted at C++26 but not yet confirmed as fully adopted.

Runtime reflection is out of scope. std::meta::info values do not exist at runtime, and P2996 makes no guarantees about runtime metadata. Building a runtime dispatch table using reflection at compile time and storing it as a static array is entirely achievable, but it is opt-in construction rather than a built-in feature.

Where Things Stand

The Bloomberg Clang fork is available on Compiler Explorer as an experimental build under the P2996 label. Upstream Clang integration progressed through the 2025 release cycle. GCC does not yet have a complete P2996 implementation as of early 2026, with work ongoing toward the C++26 draft. MSVC has not announced a timeline.

The C++26 standard is expected to finalize in 2026, with compiler vendors typically shipping stable support over the following year or two. Reflection proposals have circulated in WG21 since at least 2003, passing through several designs that each stalled on the same fundamental tension: the tools that exist to manipulate types in C++ are powerful but painful to use, and every type-based reflection proposal inherited that pain. P2996 resolved the tension by stepping outside it entirely. When metadata is data, you write data processing code, and C++ has always been good at that.

Was this interesting?