C++26 Reflection Basics: the ^^ Operator, std::meta::info and template for

Key takeaways

C++26 adopted static reflection (P2996) together with expansion statements, annotations and define_static_array. Many examples online use obsolete syntax that no longer compiles. This guide uses the adopted form: ^^ to get a std::meta::info, consteval metafunctions with access_context, template for to iterate members, splices to turn reflections back into code, plus enum-to-string, a JSON serializer, and the current compiler situation.

Introduction: Iterating Struct Members Without Writing Them Out

As structs like User, Order and Product multiply, hand-written JSON serialization, database mapping and logging code has to change every time a member is added or renamed. Forget one, and the code still compiles; the field just silently goes missing from the output.

C++ has worked around this for years with macros, manual registration and tricks like Boost.PFR. C++26 finally brings static reflection into the standard. At the June 2025 WG21 meeting, P2996 “Reflection for C++26” was adopted along with the papers that make it practical: template for expansion statements, [[=...]] annotations, std::define_static_array, and access_context.

One caveat up front. The API changed several times during standardization, so a lot of example code online — the single-caret ^T operator, [:expand(...):], std::meta::name_v<^^T>, nonstatic_data_members_of(^T) without an access context, or plain for (constexpr ...) loops — does not match the adopted syntax and does not compile. The examples here use the adopted form (as of P2996R13) and can be tried with GCC 16 or clang-p2996 on Compiler Explorer.


The Big Picture

Runtime reflection (Java, C#)C++26 static reflection
When queries happenAt run timeAt compile time
Binary sizeIncludes metadata tablesOnly the code you generate
Runtime costPer lookupNone (same as hand-written code)
Find an arbitrary type by stringBuilt inBuild your own registry

C++26 reflection is easiest to understand as three tools:

  1. ^^ (the reflection operator) turns an entity (type, variable, member, function, namespace, enumerator) into a value of type std::meta::info.
  2. std::meta:: metafunctions are consteval functions that take an info and return information such as its name, type, or list of members.
  3. [: r :] (a splice) turns an info back into code: a type, an expression, or a member access.

Add template for (an expansion statement) and you can iterate a list of members, expanding code once per member.

flowchart LR
  A["Code entity\nstruct User"] -->|"^^"| B["std::meta::info\n(compile-time value)"]
  B -->|"std::meta::*\nmetafunctions"| C["members, names, types"]
  C -->|"template for"| D["code expanded per member"]
  B -->|"[: r :]"| E["back to code\ntype, expression, member access"]

The ^^ Operator and std::meta::info

#include <meta>

struct Point { int x; int y; };

constexpr std::meta::info r_point = ^^Point;     // reflect a type
constexpr std::meta::info r_int   = ^^int;
constexpr std::meta::info r_x     = ^^Point::x;  // reflect a member

The operator was originally proposed as a single ^, but that collides with Clang’s Objective-C blocks extension, so it became ^^.

std::meta::info is an opaque scalar type whose values exist only at compile time. Variables of this type must be constexpr, and functions that work with them must be consteval or be called in a constant-evaluated context. Storing an info in a runtime variable is a compile error. You will also see consteval std::meta::info r = ... in some examples; consteval only applies to functions, so that is wrong too.

Reflections of the same entity compare equal (^^int == ^^int) and reflections of different entities do not.


Querying with Metafunctions

You only need a handful of metafunctions for most work:

MetafunctionReturnsMeaning
identifier_of(r)std::string_viewDeclared name; constant evaluation fails for unnamed entities
has_identifier(r)boolCheck for a name first
display_string_of(r)std::string_viewHuman-readable string (implementation-specific, for logging)
type_of(r)infoType of a member or variable
nonstatic_data_members_of(r, ctx)std::vector<info>Non-static data members in declaration order
enumerators_of(r)std::vector<info>Enumerators
bases_of(r, ctx)std::vector<info>Base classes
is_public(r), is_static_member(r), …boolProperty checks
#include <meta>
#include <string_view>

struct Point { int x; int y; };

static_assert(std::meta::identifier_of(^^Point) == "Point");
static_assert(std::meta::identifier_of(^^Point::x) == "x");
static_assert(std::meta::type_of(^^Point::x) == ^^int);

access_context: Which Members Can You See?

Functions that list members take an access context as their second argument. This was added late in standardization, so older examples omit it.

constexpr auto ctx_cur = std::meta::access_context::current();   // only members accessible from here
constexpr auto ctx_all = std::meta::access_context::unchecked(); // everything, including private
auto members = std::meta::nonstatic_data_members_of(^^Point, ctx_all);

Being able to read private members through reflection raised concerns about encapsulation; the compromise is that code must state explicitly when it bypasses access control. Serialization code that needs every field uses unchecked(); ordinary code uses current().


Iterating Members with template for

nonstatic_data_members_of returns a std::vector<std::meta::info>, which can only exist at compile time. You cannot walk it with a plain for loop and splice each element, because each member needs different code with different types. C++26’s expansion statement, template for, solves this: the compiler expands the loop body once per element and makes the loop variable a constant in each copy.

To feed a compile-time vector to template for, it has to become an array with static storage. That is what std::define_static_array does.

#include <meta>
#include <iostream>
#include <string>

struct User {
    int id;
    std::string name;
    bool active;
};

template <typename T>
void print_fields(const T& obj) {
    constexpr auto ctx = std::meta::access_context::unchecked();
    template for (constexpr std::meta::info m :
                  std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) {
        std::cout << std::meta::identifier_of(m) << " = " << obj.[:m:] << '\n';
    }
}

int main() {
    User u{1, "Alice", true};
    print_fields(u);
}
// id = 1
// name = Alice
// active = 1

obj.[:m:] is a member-access splice. When m reflects User::name, the expression compiles exactly like obj.name. Add a member to User and this function prints it with no changes.

Enum ↔ String

Enum name conversion is the most immediately useful application. Until now magic_enum did this by parsing the compiler’s __PRETTY_FUNCTION__ string, which is why it has a value-range limit (−128 to 127 by default). With C++26 you walk the enumerators directly:

#include <meta>
#include <string_view>
#include <optional>
#include <type_traits>

template <typename E> requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value) {
    template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E))) {
        if (value == [:e:]) return std::meta::identifier_of(e);
    }
    return "<unknown>";
}

template <typename E> requires std::is_enum_v<E>
constexpr std::optional<E> string_to_enum(std::string_view name) {
    template for (constexpr auto e : std::define_static_array(std::meta::enumerators_of(^^E))) {
        if (name == std::meta::identifier_of(e)) return [:e:];
    }
    return std::nullopt;
}

enum class Color { Red, Green = 10, Blue = 1000 };
static_assert(enum_to_string(Color::Blue) == "Blue");     // no value-range limit
static_assert(string_to_enum<Color>("Green") == Color::Green);

Example: Automatic JSON Serialization

When the generated code depends on a member’s type, the most readable approach is to take the spliced expression’s type with decltype and branch with if constexpr.

#include <meta>
#include <string>
#include <vector>
#include <type_traits>

template <typename T> std::string to_json(const T& v);

inline std::string quote(const std::string& s) {
    std::string out = "\"";
    for (char c : s) { if (c == '"' || c == '\\') out += '\\'; out += c; }
    return out + "\"";
}

template <typename T>
std::string to_json_value(const T& v) {
    if constexpr (std::is_same_v<T, bool>)             return v ? "true" : "false";
    else if constexpr (std::is_arithmetic_v<T>)        return std::to_string(v);
    else if constexpr (std::is_same_v<T, std::string>) return quote(v);
    else if constexpr (requires { v.begin(); v.end(); }) {
        std::string out = "[";
        bool first = true;
        for (const auto& e : v) { if (!first) out += ","; out += to_json_value(e); first = false; }
        return out + "]";
    }
    else return to_json(v);   // recurse into nested structs
}

template <typename T>
std::string to_json(const T& obj) {
    std::string out = "{";
    bool first = true;
    constexpr auto ctx = std::meta::access_context::unchecked();
    template for (constexpr auto m :
                  std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) {
        if (!first) out += ",";
        first = false;
        out += quote(std::string(std::meta::identifier_of(m))) + ":";
        using M = std::remove_cvref_t<decltype(obj.[:m:])>;
        out += to_json_value<M>(obj.[:m:]);
    }
    return out + "}";
}

struct Address { std::string city; int zip; };
struct Customer { int id; std::string name; std::vector<int> orders; Address address; };

// to_json(Customer{7, "Kim", {1, 2}, {"Seoul", 4520}})
// {"id":7,"name":"Kim","orders":[1,2],"address":{"city":"Seoul","zip":4520}}

In practice you will probably use a library that has started supporting reflection rather than writing this yourself; Glaze, for example, already has an experimental P2996 path. Writing it once is still worthwhile: it shows what such a library does for you and where to look when a compile error appears. The most common error when writing this for the first time is forgetting std::define_static_array, which produces an error saying the range of the expansion statement is not a constant expression.

A Pitfall When Comparing Types

You can branch on type_of(m) == ^^std::string, but std::string is an alias for std::basic_string<char>, and a reflection can refer to the alias itself, so the comparison may not behave as expected. std::meta::dealias strips aliases, but getting the real type with decltype and comparing with ordinary traits (std::is_same_v), as above, is less error-prone.


Annotations [[=...]]

To attach per-field settings such as “skip in serialization” or “use a different JSON key”, use C++26 annotations (P3394). They look like attributes, but after the = you write a constant-expression value.

#include <meta>
#include <string>
#include <string_view>

struct skip_t {};
inline constexpr skip_t skip{};

struct rename { std::string_view name; };

struct Account {
    int id;
    [[=rename{"user_name"}]] std::string name;
    [[=skip]] std::string password_hash;   // not serialized
};

On the reflection side, annotations_of_with_type(m, ^^T) finds annotations of a given type and std::meta::extract<T>(a) gets the value out:

template for (constexpr auto m :
              std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) {
    if constexpr (std::meta::annotations_of_with_type(m, ^^skip_t).empty()) {
        constexpr auto renames = std::define_static_array(
            std::meta::annotations_of_with_type(m, ^^rename));
        constexpr std::string_view key = renames.size() > 0
            ? std::meta::extract<rename>(renames[0]).name
            : std::meta::identifier_of(m);
        // serialize using key and obj.[:m:]
    }
}

The key difference from ordinary attributes (like [[nodiscard]], which a compiler may ignore if it does not know them) is that annotations carry a value that reflection can read. It is the standard C++ counterpart of declarative settings like Rust’s #[serde(rename = "...")].


Splice Syntax Summary

FormMeaningExample
[: r :]Expression (variable, enumerator, function)if (v == [:e:])
typename [: r :]Typetypename [: ^^int :] x = 0;
obj.[: r :]Member accessobj.[:m:]
&[: r :]Pointer to member for a non-static memberauto p = &[: ^^Point::x :];
template [: r :]Templatetemplate [: r :]<int>

In dependent contexts such as inside a template, a splice that names a type needs typename in front, for the same reason you write typename T::value_type.


Compiler Support and Fallbacks

The situation as of 2026:

  • GCC 16: provides a P2996 implementation with -std=c++26 -freflection. Depending on version and build, template for may need an additional flag, so check the release notes.
  • Clang: still being merged upstream. Bloomberg’s clang-p2996 fork follows the proposal most closely and is available on Compiler Explorer as the “x86-64 clang (reflection)” family of compilers.
  • MSVC: no public implementation as of September 2026.

Libraries that must support several compilers can split paths with feature-test macros:

#if defined(__cpp_impl_reflection)
  #include <meta>
  #define HAS_STD_REFLECTION 1
#else
  #define HAS_STD_REFLECTION 0
#endif

__cpp_impl_reflection indicates the language feature and __cpp_lib_reflection the <meta> library support. Early implementations may report different values, so branch on whether the macro is defined before comparing specific numbers.

Alternatives on C++17/20

  • Boost.PFR: iterates the fields of aggregate structs by index. With C++20 and Boost 1.84+, boost::pfr::get_name<I, T>() also returns field names. It needs no registration, making it the closest thing to reflection before C++26. It does not work for classes with constructors, base classes, or private members.
#include <boost/pfr.hpp>
#include <iostream>

struct Point { int x; int y; };

int main() {
    Point p{10, 20};
    boost::pfr::for_each_field(p, [](const auto& field, std::size_t i) {
        std::cout << i << ": " << field << '\n';
    });
    std::cout << boost::pfr::get_name<0, Point>() << '\n';   // "x" (C++20, Boost 1.84+)
}
  • magic_enum: enum ↔ string. Has a value-range limit (−128 to 127 by default); widening it increases compile time.
  • Macros that list fields once: e.g. NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(User, id, name). You still have to add new fields to the macro, but the chance of missing one is much lower than with fully hand-written serializers.
  • Registration-based runtime reflection such as RTTR: suits cases like game editors that must look up types and properties by string at run time. You maintain registration code separately, and the library is not very actively maintained.

C++26 reflection will not replace these overnight. Until compiler support is widespread, a gradual path works well: keep the field list in one place with Boost.PFR or a macro, and replace just that part with reflection when you move to a supporting compiler.


Common Mistakes

SymptomCauseFix
^T is a syntax errorPre-adoption single-caret operatorUse ^^T
name_v, members_of<...>, [:expand(...):] not foundObsolete early APIFunction calls such as identifier_of(r), nonstatic_data_members_of(r, ctx), and template for
”range of expansion statement is not a constant expression”Passing std::vector<info> directly to template forWrap it in std::define_static_array(...)
Wrong number of arguments to nonstatic_data_members_ofMissing access_context (old examples)Add access_context::current() or unchecked()
Error declaring an info variableconsteval variable, or missing constexprconstexpr std::meta::info r = ^^T;
identifier_of fails constant evaluationUnnamed entity (anonymous member, some specializations)Check has_identifier first or use display_string_of
Type comparison is always falseComparing an alias (std::string) to the underlying typeUse dealias, or decltype + is_same_v
is_class(^T) style checks don’t compileOld spellingstd::meta::is_class_type(^^T), or plain std::is_class_v<T>

References