Static Reflection: Compile-Time Introspection in C++26
C++26 static reflection (P2996): the ^^ operator, std::meta::info, splicing, expansion statements, and practical uses — enum stringification, struct iteration, JSON/SQL codegen.
Static reflection, adopted for C++26 via P2996, is the most significant addition to C++ metaprogramming since the introduction of templates. It gives programs the ability to observe their own structure at compile time — enumerate enumerators, iterate struct members, query type properties — without macros, without external code generators, and with zero runtime overhead.
This page covers the core language mechanisms and the practical uses that motivate them. Compiler support is still maturing (experimental Clang fork available on Godbolt), but the feature is locked into C++26 and is stable enough to study now.
Why reflection matters
Today, implementing generic utilities that work over arbitrary user types requires painful workarounds:
- Enum-to-string:
magic_enumworks through a compiler hack (counting__PRETTY_FUNCTION__expansions in a range). It is limited to values in[-128, 128)by default. - Struct iteration:
Boost.PFRuses aggregate-initialization tricks to get members by index but cannot access member names. - Serialization (JSON, binary, SQL): All existing zero-boilerplate libraries require either macros, special type registration, or generated code.
Static reflection eliminates all these workarounds. The compiler already has all this information; P2996 exposes it to you.
The three mechanisms
Everything in P2996 builds on three interacting language features:
- The
^^operator — maps a program entity to a compile-time reflection value - The splice
[: ... :]— maps a reflection value back into a program entity - Expansion statements (
template for) — iterate over a compile-time range of (heterogeneous) reflection values
Together these form the complete reflection loop: reflect → query/transform → splice back.
The ^^ operator
^^entity produces a value of type std::meta::info — an opaque compile-time handle to any reflectable entity:
#include <meta>
constexpr std::meta::info r_int = ^^int;
constexpr std::meta::info r_string = ^^std::string;
struct Point { double x, y; };
constexpr std::meta::info r_point = ^^Point;
enum class Color { Red, Green, Blue };
constexpr std::meta::info r_color = ^^Color;
// Also works on variables, functions, namespaces
void foo() {}
constexpr std::meta::info r_foo = ^^foo;
constexpr std::meta::info r_ns = ^^std;
constexpr std::meta::info r_global = ^^::;std::meta::info is a scalar type. Two reflections are equal (==) if and only if they refer to the same entity. It can be used as a template argument.
The ^^ operator uses double-caret (not single) to avoid collision with Objective-C++ block syntax (see P3381R0).
Querying reflections
The std::meta namespace provides functions to interrogate a std::meta::info value:
Type queries
static_assert(std::meta::is_type(^^int));
static_assert(std::meta::is_class_type(^^Point));
static_assert(std::meta::is_enum_type(^^Color));
static_assert(!std::meta::is_const_type(^^int));Name queries
constexpr std::string_view name = std::meta::identifier_of(^^Point);
// name == "Point"
constexpr std::string_view full = std::meta::qualified_name_of(^^std::string);
// full == "std::basic_string<char, ...>" (implementation-defined detail)Member enumeration
// All non-static data members of a struct
constexpr auto members = std::meta::nonstatic_data_members_of(^^Point);
// Returns a range of std::meta::info values, one per member
// All enumerators of an enum
constexpr auto enums = std::meta::enumerators_of(^^Color);
// Base classes
constexpr auto bases = std::meta::base_classes_of(^^SomeClass);
// Member functions
constexpr auto methods = std::meta::member_functions_of(^^SomeClass);Splicing: [: ... :]
The splice operator [: r :] is the inverse of ^^. Given a reflection r, it reintroduces the reflected entity back into the program:
using T = [: ^^int :]; // T is int
constexpr Color c = [:^^Color::Red:]; // c is Color::Red
// Access a member by its reflection
Point p{1.0, 2.0};
double val = p.[: ^^Point::x :]; // val is 1.0Splicing into a type position, expression position, or member-access position is all valid. The key invariant: [: ^^E :] is E.
Expansion statements
template for iterates over a compile-time range of reflections. Each iteration binds a constexpr std::meta::info variable — the loop body is instantiated once per element, potentially with different types each time:
template for (constexpr std::meta::info m : std::meta::nonstatic_data_members_of(^^Point)) {
// m is a different reflection each iteration
// The type of obj.[:m:] is different each iteration
}This is not a runtime loop — the compiler unrolls it at instantiation time, similar to a fold expression but more expressive.
Enum stringification
The canonical example. Before C++26 you needed magic_enum or macros; now:
#include <meta>
#include <string>
#include <string_view>
template <typename E>
requires std::is_enum_v<E>
std::string to_string(E value) {
template for (constexpr std::meta::info e : std::meta::enumerators_of(^^E)) {
if (value == [:e:]) {
return std::string(std::meta::identifier_of(e));
}
}
// Unknown value: return "(EnumName)numeric"
return "(" + std::string(std::meta::identifier_of(^^E)) + ")"
+ std::to_string(std::to_underlying(value));
}
// Usage
enum class Color { Red, Green, Blue };
std::string s = to_string(Color::Green); // "Green"
std::string u = to_string(Color{99}); // "(Color)99"The compiler unrolls the template for into a chain of comparisons — identical to hand-written code, zero overhead. Unlike magic_enum, this works for any enumerator value, not just those in a restricted range.
Building a compile-time lookup table
For large enums, a sorted table + binary search beats linear scan:
template <typename E>
requires std::is_enum_v<E>
consteval auto make_enum_table() {
constexpr auto enumerators = std::meta::enumerators_of(^^E);
std::array<std::pair<std::underlying_type_t<E>, std::string_view>,
enumerators.size()> table{};
std::size_t i = 0;
template for (constexpr std::meta::info e : enumerators) {
table[i++] = {
std::to_underlying([:e:]),
std::meta::identifier_of(e)
};
}
std::sort(table.begin(), table.end(),
[](auto& a, auto& b) { return a.first < b.first; });
return table;
}The table is computed at compile time and embedded as a constant in the binary.
Struct member iteration
Iterate all non-static data members, access names and values:
template <typename T>
void print_fields(const T& obj) {
template for (constexpr std::meta::info m : std::meta::nonstatic_data_members_of(^^T)) {
std::cout << std::meta::identifier_of(m)
<< " = " << obj.[:m:] << '\n';
}
}
struct User { int id; std::string name; double score; };
User u{42, "Alice", 9.5};
print_fields(u);
// id = 42
// name = Alice
// score = 9.5The member type in each iteration is decltype(obj.[:m:]) — which changes per iteration, so the body must be generic-friendly (like a lambda template or use if constexpr):
template <typename T>
void serialize_json(const T& obj, std::string& out) {
out += '{';
bool first = true;
template for (constexpr std::meta::info m : std::meta::nonstatic_data_members_of(^^T)) {
if (!first) out += ',';
first = false;
out += '"';
out += std::meta::identifier_of(m);
out += "\":";
using MemberType = typename [: std::meta::type_of(m) :];
if constexpr (std::is_same_v<MemberType, std::string>) {
out += '"';
out += obj.[:m:];
out += '"';
} else {
out += std::to_string(obj.[:m:]);
}
}
out += '}';
}This is the skeleton of a zero-boilerplate JSON serializer. No macros, no registration, no code generation step.
Type introspection: querying properties of a type
constexpr std::meta::info r = ^^std::vector<int>;
// structural queries
bool is_cls = std::meta::is_class_type(r); // true
bool is_tmpl = std::meta::is_specialization_of(
r, ^^std::vector); // true
// access-control
// std::meta::is_public(^^User::name) // member access specifier
// std::meta::is_static(^^User::name) // false for nonstatic
// type of a member
constexpr std::meta::info member_r = std::meta::nonstatic_data_members_of(^^User)[1];
constexpr std::meta::info member_type = std::meta::type_of(member_r);
// member_type reflects std::stringSQL column/value generation (compile-time)
Extending the struct iteration pattern to a practical use case:
template <typename T>
consteval std::string generate_insert_columns() {
std::string cols;
bool first = true;
template for (constexpr std::meta::info m : std::meta::nonstatic_data_members_of(^^T)) {
if (!first) cols += ", ";
first = false;
cols += std::meta::identifier_of(m);
}
return cols;
}
// generate_insert_columns<User>() returns "id, name, score"
// Evaluated at compile time — the string is a constantThe column list is computed during compilation; at runtime you just use the precomputed string. This is categorically faster than any approach that inspects types at runtime.
Querying function signatures
P2996 also reflects functions, their parameters, and return types:
void foo(int x, double y);
constexpr auto params = std::meta::parameters_of(^^foo);
// params[0] reflects the 'x: int' parameter
// params[1] reflects the 'y: double' parameter
constexpr auto ret = std::meta::return_type_of(^^foo);
// ret reflects voidThis enables generating dispatch tables, wrappers, and serialization stubs automatically.
Compiler support
P2996 is part of C++26 (ratified 2024 at the St. Louis meeting). Mainstream compiler support is still in progress:
| Compiler | Status |
|---|---|
| Clang (experimental P2996 fork) | Available on Godbolt (clang-p2996) |
| GCC | Experimental, partial |
| MSVC | Planned |
The Bloomberg-maintained Clang fork supports most of P2996 but may lag on expansion statement refinements. Use Godbolt to experiment without a local compiler build.
What P2996 does NOT include
P2996 is deliberately scoped. The following are not in C++26 static reflection:
- Runtime reflection — all reflection happens at compile time. There is no
typeid-like runtime equivalent in P2996. - Modifying existing types — you can generate new types with
define_aggregate(experimental), but you cannot add members to existing types. - Function body reflection — you can reflect function signatures but not the body/AST of a function.
- Annotations — P2996 does not include a way to annotate members with metadata (see P2996 follow-on proposals like P3394 for this).
Comparison: today vs. C++26
| Task | Today | C++26 |
|---|---|---|
| Enum to string | magic_enum (range-limited) or macros | template for over enumerators_of(^^E) |
| Struct member names | Not possible without macros | identifier_of on nonstatic_data_members_of(^^T) |
| Generic struct iteration | Boost.PFR (index-only, no names) | template for with [:m:] splicing |
| JSON serialization | Manual, macro-registered, or generated | Reflection-based library works on any type |
| Count enum values | magic_enum::enum_count<E>() | std::meta::enumerators_of(^^E).size() |
| Type name at compile time | std::source_location workaround | std::meta::identifier_of(^^T) |
Key std::meta functions reference
| Function | What it returns |
|---|---|
enumerators_of(^^E) | Range of reflections for each enumerator of E |
nonstatic_data_members_of(^^T) | Range of reflections for each non-static data member of T |
base_classes_of(^^T) | Range of reflections for base classes of T |
member_functions_of(^^T) | Range of reflections for member functions of T |
parameters_of(^^f) | Range of reflections for parameters of function f |
return_type_of(^^f) | Reflection of the return type of f |
type_of(r) | Reflection of the type of a member/variable reflection r |
identifier_of(r) | std::string_view of the unqualified name |
qualified_name_of(r) | std::string_view of the fully qualified name |
is_type(r) | True if r reflects a type |
is_class_type(r) | True if r reflects a class/struct |
is_enum_type(r) | True if r reflects an enum |
is_public(r) | True if the reflected entity has public access |
is_static(r) | True if the reflected entity is static |
Generating new types at compile time with define_aggregate
std::meta::define_aggregate is a consteval function in P2996 that synthesizes a brand-new struct type at compile time from a list of member descriptions — it is the only mechanism in C++26 that lets the programmer create a type that does not exist in source code. This enables patterns that were previously only achievable with code generators or macros: building a "row" type for a compile-time schema, projecting a struct to a subset of its fields, or constructing a flattened view of a class hierarchy.
#include <meta>
#include <vector>
#include <string_view>
// Describe a set of members: name + type reflection
struct MemberSpec {
std::string_view name;
std::meta::info type;
};
// Synthesize a new aggregate struct at consteval time
consteval std::meta::info make_row_type(std::span<const MemberSpec> specs) {
std::vector<std::meta::info> member_reflections;
for (auto& spec : specs) {
// data_member_spec() constructs a description of a single member
member_reflections.push_back(
std::meta::data_member_spec(spec.type,
{.name = spec.name}));
}
// define_aggregate synthesizes the struct and returns a reflection of it
return std::meta::define_aggregate(^^::, member_reflections);
}
// Build a "schema" at compile time:
constexpr MemberSpec schema[] = {
{"id", ^^int},
{"score", ^^double},
{"tag", ^^std::string_view},
};
// Splice the generated type into a using declaration:
using Row = [: make_row_type(schema) :];
// Row is now equivalent to:
// struct Row { int id; double score; std::string_view tag; };
Row r{ .id = 1, .score = 9.8, .tag = "gold" };define_aggregate is the compile-time equivalent of runtime struct definition in dynamic languages. The generated type is a first-class C++ aggregate: it participates in overload resolution, can be used as a template argument, supports structured bindings, and has all the usual layout guarantees. One important constraint: define_aggregate can only be called in consteval context — the type it creates is known to the compiler at translation time but does not exist in the user's source.
Structured bindings via reflection
C++26 reflection gives you the tools to implement the structured-binding customization protocol generically — instead of hand-writing get<N>, tuple_size, and tuple_element specializations for each type, you derive them from the type's reflection. This means any aggregate can be made bindable without boilerplate.
The standard binding protocol requires three specializations. With reflection you can write them once, generically:
// Generic tuple_size: count non-static data members via reflection
template<typename T>
requires std::meta::is_class_type(^^T)
struct std::tuple_size<T>
: std::integral_constant<std::size_t,
std::meta::nonstatic_data_members_of(^^T).size()> {};
// Generic tuple_element: get the type of the N-th member
template<std::size_t N, typename T>
requires std::meta::is_class_type(^^T)
struct std::tuple_element<N, T> {
using type = typename [:
std::meta::type_of(
std::meta::nonstatic_data_members_of(^^T)[N])
:];
};
// Generic get<N>: return a reference to the N-th member
template<std::size_t N, typename T>
requires std::meta::is_class_type(^^T)
decltype(auto) get(T&& obj) {
constexpr auto member =
std::meta::nonstatic_data_members_of(^^T)[N];
return std::forward<T>(obj).[: member :];
}
// Now any simple aggregate can be bound without extra boilerplate:
struct Vec3 { float x, y, z; };
auto [a, b, c] = Vec3{1.f, 2.f, 3.f};
// a=1.f, b=2.f, c=3.f — no hand-written tuple_size/element/get requiredThe reflection-based approach is strictly correct for aggregates: it respects the declaration order of members, handles types of arbitrary member types, and works even when members have differing types (since template for generates the right code per member). The one limitation: access control. nonstatic_data_members_of by default returns only public members; for private-member binding you would need a friend declaration and the access_context parameter introduced in later P2996 revisions.
Reflection and concepts
Reflection and concepts are orthogonal mechanisms, but combining them lets you write concept definitions that check structural constraints that are impossible to express with requires expressions alone — for example, "a type whose members are all numeric" or "a type with exactly N members of a specific naming convention".
A concept can call consteval functions, and those functions can use std::meta to inspect the type:
#include <meta>
#include <type_traits>
// Concept: every non-static data member of T must be an arithmetic type
template<typename T>
concept AllNumericMembers = std::meta::is_class_type(^^T) && []() consteval {
for (auto m : std::meta::nonstatic_data_members_of(^^T)) {
// type_of(m) gives the reflection of the member's type
if (!std::meta::is_arithmetic_type(std::meta::type_of(m)))
return false;
}
return true;
}();
struct Vec2 { float x, y; };
struct Mixed { int n; std::string s; };
static_assert( AllNumericMembers<Vec2>); // OK
static_assert(!AllNumericMembers<Mixed>); // std::string is not arithmetic
// Use as a concept constraint:
template<AllNumericMembers T>
T component_sum(const T& v) {
T::value_type result{}; // (would need a separate concept for this)
template for (constexpr std::meta::info m : std::meta::nonstatic_data_members_of(^^T))
result += v.[:m:];
return result;
}You can also use reflection inside requires expressions via immediately-invoked consteval lambdas, checking member counts, member names matching a naming convention, or whether a type is a specialization of a particular template:
// Concept: T must have a member named "value" of integral type
template<typename T>
concept HasIntegralValue = requires {
// Standard requires expression checks syntactic validity:
requires std::integral<decltype(std::declval<T>().value)>;
} && []() consteval {
// Reflection adds semantic checks: confirm "value" is non-static data member
for (auto m : std::meta::nonstatic_data_members_of(^^T))
if (std::meta::identifier_of(m) == "value")
return true;
return false;
}();The interplay between concepts and reflection is still being standardized. The current consensus is that concept predicates can freely call consteval functions that use reflection, but reflection cannot directly appear in a requires-expression without the immediately-invoked lambda wrapper.
Limitations and follow-on proposals
P2996 is deliberately a minimal, solid foundation — several important capabilities were intentionally deferred to follow-on proposals to keep C++26 tractable. Knowing what is and is not in scope prevents frustration when the feature does not do what you expect.
What P2996 does not address (and what comes next)
P3394 — Annotations (C++29 candidate). P2996 has no way to attach user-defined metadata to declarations. P3394 proposes [[=expr]] annotations that are visible to reflection:
// P3394 syntax (not in C++26):
struct User {
[[= Column{"users", "user_id"}]] int id;
[[= Column{"users", "display_name"}]] std::string name;
};
// Then at compile time:
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^^User)) {
constexpr auto ann = std::meta::annotation_of<Column>(m);
// ann.table == "users", ann.column == "user_id" / "display_name"
}Without P3394, implementing ORM-style mappings requires naming conventions or wrapper types, which is fragile.
P2237 — Metaprogramming (extended facilities). P2237 proposes additional std::meta functions for reflecting template arguments, more complete function-body inspection, and richer source-location data. It targets C++29 but portions may be integrated earlier.
Runtime reflection. Neither P2996 nor any current proposal adds runtime reflection to C++. Accessing type information at runtime still requires std::type_info / typeid, virtual dispatch, or manual registration. The C++ committee has explicitly scoped static reflection as compile-time only.
Modifying existing types. define_aggregate creates new types; there is no mechanism to inject members into an existing class. This is a deliberate safety boundary — retroactive member injection would break ODR and make type layout unpredictable.
Practical limitations in the C++26 implementation
// 1. Private members are NOT returned by default:
struct Impl {
int pub;
private:
int priv_; // NOT in nonstatic_data_members_of(^^Impl) by default
};
// 2. Bit-fields are reflected but splicing into them has restrictions:
struct Flags { unsigned a : 4; unsigned b : 4; };
// obj.[:m:] for a bit-field member is read-only in P2996 (no address-of)
// 3. Function bodies are not reflectable:
// std::meta::body_of(^^foo) does not exist — you can reflect the signature
// but not the implementation.
// 4. Template parameters of a function are only partially reflected:
// std::meta::template_arguments_of works on type specializations,
// but reflecting a function template's parameter pack is limited.C++26 static reflection is nonetheless the most impactful metaprogramming addition since templates themselves. The foundation P2996 provides — reflect, query, splice, expand — is sufficient to eliminate most macro-based and code-generation-based boilerplate in modern C++ codebases.