Every big C++ codebase eventually needs a serializer, and every one of them has been written by hand, generated by a Python script or other C++ code, or dragged in behind a 17-layer-deep macro. C++26 finally puts reflection in the language, and I wanted to give it a try. As of writing (16/08/2026) GCC 16.1.0 is the only compiler that supports it, poor MSVC :( So after downloading the correct version I set out to write a very basic struct to JSON writer.

The version I did not want to write

Here is the baseline. Given a struct, walk the fields by hand and hope you remember to update the writer when someone adds a field.

struct TestStruct {
    int                 number;
    float               realNumber;
    std::string         str;
    TestSubStruct       sub;
    std::vector<int>    ids;
    std::vector<Vec3>   vecs;
};

void WriteJsonInto1( TestStruct data, std::stringstream & ss ) {
    ss << "{";
    ss << "number:\"" << std::to_string(data.number) << "\",";
    ss << "realNumber:\"" << std::to_string(data.realNumber) << "\",";
    ss << "str:\"" << data.str << "\"";
    ss << "}";
}

It's fine, it works. Super boring, super tedious, super frustrating. Every time you add something to the struct you have to remember to add it to the serializer. But what if we could get the compiler to auto generate this code? Introducing C++26 reflection.

Three new pieces of syntax

Reflection in C++26 rests on a small number of additions. Everything below is built out of these.

The reflection operator ^^ takes a type, a namespace, a variable or an expression and gives you back a value of type std::meta::info. This is a normal constexpr value. You can put it in an array, pass it to a function, compare it. It just happens to describe a piece of your program.

A splicer [: ... :] goes the other way. Give it an std::meta::info and it turns back into the thing it describes, in whatever position you wrote it. data.[:m:] is a member access. typename [:t:] is a type.

Finally template for, an expansion statement. The compiler unrolls it at compile time and the loop variable is a constant in each copy of the body.

Json Serializer 1

Putting those together and the field walk collapses into a few lines.

void WriteJsonInto2( TestStruct data, std::stringstream & ss ) {
    constexpr auto TestType = ^^TestStruct;
    static constexpr auto testMembers = std::define_static_array(
        std::meta::nonstatic_data_members_of( TestType, std::meta::access_context::current() ) );

    ss << "{";
    template for ( constexpr auto m : testMembers ) {
        if constexpr ( HasToString<typename [:std::meta::type_of(m):]> ) {
            ss << "\"" << std::meta::identifier_of(m) << "\":\"" << ToString(data.[:m:]) << "\",";
        }
    };
    ss.seekp(-1, std::ios_base::end);
    ss << "}";
}

Reading it left to right: nonstatic_data_members_of hands back a std::vector<std::meta::info>, one entry per field. That vector lives in the constant evaluator and cannot escape into runtime on its own, so std::define_static_array copies it into an array with static storage duration and gives you a span over it. Otherwise you get a compile error.

The access_context::current() argument answers "who is asking". Reflection respects access control, so a query from outside the class sees the public fields and a query from a member function sees everything. So basically, do you want the public or the private variables?

Then the loop body does two things with each field. identifier_of(m) gives me the field name as it was spelled in the source. data.[:m:] gives the field itself, as a real lvalue of the right type.

The dispatch is if constexpr over a concept:

template<typename _type_>
concept HasToString = requires ( const _type_ & s ) {
    { ToString(s) } -> std::convertible_to<std::string>;
};

typename [:std::meta::type_of(m):] splices the field's type into the concept check, so every unrolled copy of the body asks its own question and only the matching branch is compiled. Reflection gives the members, and the concept and constraint machinery does the rest.

Json Serializer 2, going recursive

Nothing in that function is specific to TestStruct except the type name, so replace it with a template parameter and reflect on that instead. ^^_type_ works on a dependent type exactly as you would expect.

template<typename _type_>
void WriteJsonInto3( _type_ data, std::stringstream & ss ) {
    constexpr auto Type = ^^_type_;
    static constexpr auto testMembers = std::define_static_array(
        std::meta::nonstatic_data_members_of( Type, std::meta::access_context::current() ) );

    ss << "{";
    template for ( constexpr auto m : testMembers ) {
        ss << "\"" << std::meta::identifier_of(m) << "\":";
        if constexpr ( HasToString<typename [:std::meta::type_of(m):]> ) {
            ss << "\"" << ToString(data.[:m:]) << "\"";
        } else {
            WriteJsonInto3(data.[:m:], ss);
        }
        ss << ",";
    };
    ss.seekp(-1, std::ios_base::end);
    ss << "}";
}

The else branch allows for sub-structs. A field the concept does not recognise is assumed to be an object, so we recurse into it, and the recursion instantiates a fresh version of the function for that field's type, which reflects on its members, and so on down.

Vectors still come out wrong, because a std::vector<int> has no members you care about and recursing into its internals is not what anyone wants.

Json Serializer 3, arrays

Better is to stop asking "does this have members" and start asking "what kind of JSON value is this". Split the writer in two. One function classifies a value, one function walks an object's fields, and they call each other.

template<typename _type_>
static void WriteJsonValue( const _type_ & value, std::stringstream & ss ) {
    if constexpr ( std::is_same_v<_type_, bool> ) {
        ss << ( value ? "true" : "false" );
    } else if constexpr ( std::is_arithmetic_v<_type_> ) {
        ss << ToString( value );
    } else if constexpr ( HasToString<_type_> ) {
        WriteJsonString( ToString( value ), ss );
    } else if constexpr ( std::ranges::range<_type_> ) {
        ss << "[";
        bool first = true;
        for ( const auto & element : value ) {
            if ( !first ) { ss << ","; }
            first = false;
            WriteJsonValue( element, ss );
        }
        ss << "]";
    } else {
        WriteJsonObject( value, ss );
    }
}

template<typename _type_>
static void WriteJsonObject( const _type_ & value, std::stringstream & ss ) {
    constexpr auto Type = ^^_type_;
    static constexpr auto members = std::define_static_array(
        std::meta::nonstatic_data_members_of( Type, std::meta::access_context::current() ) );

    ss << "{";
    bool first = true;
    template for ( constexpr auto m : members ) {
        if ( first == false ) { ss << ","; }
        first = false;
        ss << "\"" << std::meta::identifier_of(m) << "\":";
        WriteJsonValue( value.[:m:], ss );
    };
    ss << "}";
}

The ordering of those branches is important. bool has to come first because it is arithmetic and would otherwise print as 1. Arithmetic goes in bare, since JSON numbers are not quoted. Anything with a ToString is a string and gets escaped properly. Anything that satisfies std::ranges::range is an array, which catches std::vector, arrays and anything else that iterates. Whatever is left over is an object, and objects are the only case that needs reflection at all.

Feeding the test struct, the output is what you would want:

{"number":2,"realNumber":5,"str":"GG",
 "sub":{"boolean":true,"otherNumber":7},
 "ids":[1,2,3],
 "vecs":[{"x":1,"y":2,"z":3},{"x":4,"y":5,"z":6}]}

Vec3 was never mentioned by name in the serializer. Neither was TestSubStruct. I added std::vector<Vec3> to the struct and the writer produced an array of objects without any change.

Oddities

The static storage dance around nonstatic_data_members_of is unavoidable. The function returns a vector because the constant evaluator is allowed to allocate, but that allocation has to be gone by the end of constant evaluation. define_static_array is the escape hatch that promotes it into the program image. I'm not sure why they decided to make it return a vector, it seems like unnecessary boilerplate, but maybe there is a use case or implementation requirement?

The float output was strange: std::to_string(5.0f) printed 5, not the 5.000000 I expected. Anyone who's dealt with float serialization to text like this knows that it usually writes as 5.000, which causes all sorts of pain with respect to floating point error, when minor drifts cause huge diffs in source control. In C++26 however, they have redefined to_string for floating point. It now uses std::format, which gives the shortest round-trippable output. Nice! FYI, one approach to fixing the floating point error is to write out the float in hex, though then it's not human readable.

Building it

This needs a compiler with the reflection paper implemented. I used a MinGW GCC build with the feature switched on:

g++ -std=c++26 -freflection -O2 -Wall -Wextra json_struct_reflection.cpp -o json_struct_reflection.exe

Conclusion

Serialization is the obvious demo, but it is only half of the job. Reading JSON back into a struct means matching a runtime string against compile time field names, which the same identifier_of loop can do with a chain of comparisons. But 90% of that is just parsing the text/JSON file, which is just busy work, so I stopped here.

Beyond that, we can grab the enum names without having a hacky 100 line switch statement. Hurray! There are also per-field annotations/tags, which allow a struct to say "skip this one" or "call it something else in the output". [[JSON_SERIALIZE("Simplified name")]] for example.

All this will be a great help in serializing structs, populating UIs, network serialization, etc. We just have to wait for the other compilers to catch up, and hope the compile times don't explode! :D