Skip to content

Reflection

The compiler already knows what your fields are called. Where it can tell you, serpent uses that and you write no conversion code at all.

struct [[= serpent::serializable {}]] point {
    int x = 0;
    int y = 0;
};

json::encode(point { 3, 4 });   // {"x":3,"y":4}

That is the whole opt-in. The same type now reads and writes in every format.

A base's members are the object's members

A type that inherits is written whole — base members first, then its own, which is the order they were declared in and the order anyone listing them by hand would write:

struct [[= serpent::serializable {}]] identified {
    int id = 0;
};

struct [[= serpent::serializable {}]] equipment : identified {
    std::string name;
};
{"id":7,"name":"tractor"}

Only public bases, which falls out of the library asking the compiler what it can see: a base a type did not expose is not part of what it presents to the world.

A key follows the naming rule of the type that declared it, so a derived type cannot restyle the keys a base already settled — those are someone else's wire format, and changing them from a derived declaration would be a silent break.

Adjusting the keys

Identifiers are rarely the keys you want on the wire, so a type can carry a naming rule and a field can override or opt out of its own:

struct [[= serpent::serializable {},
       = serpent::naming { serpent::naming_style::snake_case }]] link_config {
    [[= serpent::key("ip")]] std::string ipAddress = "0.0.0.0";
    [[= serpent::skip {}]]   int cacheGeneration = 0;
                             int mtuBytes = 1500;   // becomes "mtu_bytes"
};
{"ip":"10.0.0.4","mtu_bytes":9000}
Annotation On Does
serializable {} a type opts it in
naming {style} a type derives every key from the identifiers
key("...") a field overrides one key
skip {} a field, or an enumerator leaves it out of the document, both directions — and an enumerator so marked is not a candidate when choosing what to write, which is what a sentinel aliased onto a real value needs
required {} an optional field the key must be present, though its value may be null
defaulted {} a plain field, or a type the document may omit it, and it keeps its declared default; on a type it means every member, and required {} on one of them is the exception
omit_empty {} an optional field, or a type an empty optional writes no key, rather than null; on a type it means every optional the type declares, and required {} on one of them is the exception
discriminant("key") a type names it on the wire under key, so a variant can select it
tagged("key") a variant field the same, decided at the field instead of on the alternatives
as(...) an enumerator what it is on the wire, where its identifier will not do
fallback {} an enumerator the one to use when nothing matches, in both directions
enum_as_name {...} a field writes the enumerations in it by name, whatever they say themselves — see At the field
enum_as_number {...} a field writes them as numbers, whatever they say themselves
with<Codec> {} / with { write, read } a field writes and reads it with these instead of by its type — see A codec for one field

Styles: as_written, snake_case, screaming_snake_case, kebab_case, camel_case, pascal_case.

Annotations are ordinary values rather than parsed strings, which is why they are written with = and braces, and always qualified.

Naming a type on the wire

A std::variant is read by asking each alternative whether the value fits, which cannot separate two types that have the same members. A discriminant gives the type a name instead:

struct [[= serpent::discriminant("unit")]] celsius    { double value; };
struct [[= serpent::discriminant("unit")]] fahrenheit { double value; };
struct [[= serpent::discriminant("unit", "K")]] kelvin { double value; };

using temperature = std::variant<celsius, fahrenheit, kelvin>;
{"unit":"fahrenheit","value":70.7}

The name defaults to the type's own identifier, which reflection already knows; the second argument overrides it. It is not a member — nothing declares it, reading a type on its own ignores it, and it is written before the fields.

Decoding a variant then reads that key and picks the alternative it names. A name that matches nothing fails, rather than falling back to a guess.

Tagging at the field instead

A discriminant on the type needs the alternatives to be yours to annotate. Where they are not, put the tag on the field:

struct point  { int x, y; };      // someone else's header
struct circle { int radius; };

struct [[= serpent::serializable {}]] drawing {
    std::string title;
    [[= serpent::tagged("kind")]] std::variant<point, circle> body;
};
{"title":"a","body":{"kind":"circle","radius":9}}

Naming the alternatives here is also what opts them in, so neither type needs to know serpent exists. They are named by their own identifiers unless you say otherwise:

[[= serpent::tagged("k", { "pt", "circ" })]] std::variant<point, circle> body;

Because the tag belongs to the field, the same types can be tagged differently in different places, which a type-level annotation cannot do.

Alternatives that are not objects

Only an alternative written as an object of its own members can carry a name — there is nowhere to put one on a number. Those keep the untagged behaviour, which is all they need:

[[= serpent::tagged("kind")]] std::variant<int, point, circle> body;
{"body":42}
{"body":{"kind":"circle","radius":9}}

A number is recovered as a number, and the objects are still told apart by name. This holds for the type-level discriminant too: alternatives that cannot carry one do not stop the ones that can from being named. A name that matches no alternative still fails, rather than falling through to the untagged attempt.

Enumerations

An enumeration that says nothing goes out as its underlying number. Annotate it and the enumerators go by their identifiers instead:

enum class [[= serpent::serializable {}]] nav_system { unknown, gps, glonass };
"glonass"

Where an identifier is not what the wire calls it, say so on the enumerator. Note the position: an enumerator's attribute follows its name.

enum class [[= serpent::serializable {}]] fix_dimension {
    none              [[= serpent::fallback {}, = serpent::as(nullptr)]],
    two_dimensional   [[= serpent::as("2d")]],
    three_dimensional [[= serpent::as("3d")]],
};

as takes null, a boolean, a whole number, a real or a string, and they may be mixed within one enumeration — a stop-bit count that is 1, 1.5 and 2 is written exactly so, and a word length maps to 5, 6, 7, 8 regardless of its underlying values. The type's naming rule applies to the identifiers it falls back on, so notConnected under kebab_case is "not-connected".

Sentinels aliased onto real values

A C API often declares bounds among its enumerators, and they are often equal to a real one — ESP_PARTITION_SUBTYPE_APP_OTA_MIN is APP_OTA_0, and it is declared first. Mark the bound skip {} and it stops being a candidate, so the value writes under the name it has rather than the bound's identifier:

app_ota_min [[= serpent::skip {}]]            = 0x10,
app_ota_0   [[= serpent::as("ota_0")]]        = 0x10,   // what 0x10 writes as

Declaration order then stops mattering, which is the point: nothing about the wire should depend on which alias the header happened to put first.

fallback {} names one enumerator as the answer when nothing else fits, in both directions: a value on the wire matching no enumerator reads as it, and an enumeration value that is not any enumerator — cast in from a number — writes as it. Without one, an unrecognised value fails to read rather than guessing.

Absent, null, unrecognised

Four things a document can do to an enumeration member, and they stay four:

The document The member gets
leaves the key out whatever its own initialiser said, if it may be left out at all
says a value the enumeration names that enumerator
says a value it does not name the fallback {}, or the read fails if there is none
says null an enumerator annotated as(nullptr), else the fallback {}, else the read fails

The default is at the member and the fallback is on the enumeration, which is the right way round: the default is what this field means when unsaid, so two structs holding the same enumeration may disagree about it, and it lives in the initialiser where a C++ programmer looks for it.

struct [[= serpent::serializable {}]] listener {
    [[= serpent::defaulted {}]] auth_method auth = auth_method::hotspot;
};

Null is a value the table does not name, so a fallback catches it

Only a fallback does. Without one, null fails the read exactly as a plain int member does when the document says null — serpent's answer for a field where null is meaningful is std::optional, which reads it as empty and needs no enumerator spent on it.

So a fallback is a total function from every wire value to an enumerator, null included. If you want null to mean one enumerator and unrecognised values another, say as(nullptr) on the one null means; it is matched before the fallback is considered.

Two enumerations may give the same spelling different meanings, which a rule derived from the identifiers could not: rtk_float is "float" in one and "rtk_float" in another.

At the field

An enumeration that has nothing to do with serialization need not say how it is written: the field holding it can. The same enumeration may then be a name in one document and a number in another.

enum class probe_state { idle, busyNow, lost };   // says nothing

struct [[= serpent::serializable {}]] status {
    [[= serpent::enum_as_name {}]] probe_state probe;                                    // "busyNow"
    [[= serpent::enum_as_name { serpent::naming_style::snake_case }]] probe_state last;  // "busy_now"
    [[= serpent::enum_as_number {}]] link_kind link;   // a number, though link_kind is named
};

An enumerator is its own as(...) where it has one, else its identifier under the field's naming rule, else the enumeration's. Entries change single enumerators for this field only — a new spelling, skip {} to keep one off the wire, fallback {} to replace the enumeration's own:

[[= serpent::enum_as_name {
    serpent::enum_entry { probe_state::idle, "waiting" },
    serpent::enum_entry { probe_state::lost, "gone", serpent::fallback {} },
}]] probe_state probe;

[[= serpent::enum_as_number { serpent::enum_entry { reachability::unknown, -1 } }]] reachability reach;

The field's choice reaches every enumeration inside an optional, a sequence, a set, a pair or a map, keys included; the containers are written as they always are. The checks an annotated enumeration gets — no two enumerators the same on the wire, one fallback at most — are made again for each field, over the mapping as the field leaves it.

A codec for one field

with hands a field to something else to write and read. A codec is a type with the two static functions a serializer<T> specialization has, so an existing specialization serves as it is; two lambdas that capture nothing will also do:

[[= serpent::with<seconds_since_boot> {}]] std::chrono::milliseconds uptime;
[[= serpent::with<serpent::serializer<timestamp>> {}]] timestamp started;

[[= serpent::with {
    [](auto &out, const int &value) { out.value(value + 100); },
    [](const auto &source, int &value) {
        const auto held = source.template as<int>();
        if (held) value = *held - 100;
        return held.has_value();
    },
}]] int offset;

The codec is given the field whole — on a std::vector<T> it gets the vector. Whether the document must carry the field is decided as for any other, by required {} and defaulted {}.

A type you cannot annotate

An annotation cannot go on a type declared in someone else's header. serpent::describe<T> is the same place for that type, from outside: specializing it is the opt-in, and what it carries is what the annotations carry, under the same names. There is one trait to learn whether you are describing a struct, an enumeration, or only a naming rule.

Every member, by a naming rule alone

A description that says nothing about individual members puts every member on the wire, exactly as serializable alone would:

template<>
struct serpent::describe<foreign_reading> {
    static constexpr serpent::naming naming { serpent::naming_style::snake_case };
};
{"sensor_id":4,"degrees_celsius":21.5}

That is the whole opt-in — the specialization existing is what turns the type on, so a description with nothing in it at all is valid and means "every member, named as written".

Members that need to say more

A members table names the type's members from outside, taking each key from the declaration rather than from a string you restate — so only the exceptions are written down, and one list serves both directions:

template<>
struct serpent::describe<esp_netif_ip_info_t> {
    static constexpr serpent::member_entry members[] {
        ^^esp_netif_ip_info_t::ip,                                       // key is "ip"
        { ^^esp_netif_ip_info_t::netmask, serpent::key("mask") },        // the exception
        { ^^esp_netif_ip_info_t::gw, serpent::defaulted {} },
    };
};

No serializer<T> is involved; the type now reads and writes in every format, strictly, exactly as an annotated one does. A member may carry key, skip, required or defaulted in any order — the annotations, spelled the same and meaning the same — and the table may carry a type-wide naming rule beside the list.

A table is held to its type

A table must account for every member the compiler can see. Leave one out and the build stops, naming it:

error: static assertion failed: this serpent::describe table does not name every member
of its type. Not named: gateway. List them, or say { member, serpent::skip {} } to keep
one off the wire deliberately, or say `static constexpr bool partial = true` if the table
is meant to be a subset

So a member added by an SDK upgrade is caught at the build that picks it up, rather than quietly disappearing from everything you write. A table that is meant to be a subset says so once:

static constexpr bool partial = true;

Where the list comes from is up to you

The list is an ordinary constexpr range of members, so hand-picking and taking all of them are the same primitive rather than two features:

static constexpr auto members = serpent::all_members_of<T>();
static constexpr auto members = std::define_static_array(
        std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::current())
        | std::views::filter([](std::meta::info member) {
              return std::meta::identifier_of(member) != "reserved";
          }));
static constexpr bool partial = true;
static constexpr serpent::member_entry members[] {
    ^^T::first,
    { ^^T::second, serpent::key("2nd") },
};

all_members_of is a convenience and nothing more — it fills the same sequence from the compiler's own list and runs the same walk. Narrowing is <ranges> and <meta> code you already know, not a filtering vocabulary this library invented.

An enumeration

An annotation cannot go on an enumeration declared in someone else's header, so the table goes in a specialization instead. It carries exactly what the annotations carry — a value per enumerator, of any of the kinds as takes, and one fallback:

template<>
struct serpent::describe<uart_stop_bits_t> {
    static constexpr serpent::enum_entry<uart_stop_bits_t> values[] {
        { UART_STOP_BITS_1,   1,   serpent::fallback {} },
        { UART_STOP_BITS_1_5, 1.5 },
        { UART_STOP_BITS_2,   2   },
        { UART_STOP_BITS_MAX, serpent::skip {} },   // a sentinel, never on the wire
    };
};

Both forms run the same lookup, so null, the mixed kinds and the fallback behave identically whichever way an enumeration was mapped. The table needs no reflection at all — it is the way to map an enumeration on a toolchain that has none.

A table is held to its type

Where reflection is available, the table is checked against the enumerators the compiler can see, and one it does not name is a compile error naming the one it missed:

error: static assertion failed: this serpent::describe table does not name every
enumerator of its type. Not named: UART_PARITY_MARK. Give each a value, or say
{ enumerator, serpent::skip {} } to keep it off the wire deliberately

Ownership only blocks attaching an annotation to an enumerator; it does not block enumerating them. So an enumerator added by an SDK upgrade is caught at the build that picks it up, rather than quietly reading and writing as the fallback.

What it does not reach

Only non-static data members can be named this way. A wrapper whose values live behind a pointer, a member that is computed rather than stored, or a type that is one scalar rather than an object has no declaration for a table to point at — those want a serializer<T>.

Unlike an enumeration's table, a members table needs reflection: addressing a member without naming it is a splice.

Requirements

Reflection needs three C++26 papers together — P2996 (reflection), P1306 (template for) and P3394 (annotations). GCC 16 has all three, behind a flag:

-std=c++26 -freflection

No other released compiler has the full set yet. The CMake build probes for it and builds the reflected tests only where it works, so a project can carry annotated types before every compiler it targets has caught up:

#if SERPENT_HAS_REFLECTION
    // annotated definition
#else
    // a json_convert naming the fields
#endif

serpent::reflection_available is the same answer as a constexpr bool. example/reflection.cpp shows a type written both ways.

The annotation is not ignorable, so a shared header needs the guard

On a compiler that does not know the syntax the annotation is a parse error, not an attribute that is quietly dropped:

error: expected ']' before '=' token

So a header compiled by more than one toolchain cannot simply carry the annotation and let older compilers skip it — it has to carry both forms behind #if SERPENT_HAS_REFLECTION. Verified against GCC 15.2 (the ESP-IDF v6.1 toolchain), which rejects it, and GCC 16.1, which accepts it.

GCC 16 without -freflection is the case to watch: it parses the annotation and then ignores it, so the type is not reflected and the first thing to complain is the converter, at the point of use. The message says so, but check -freflection is really on the command line before believing the type is at fault.

What the compiler refuses to let you get wrong

The list of members, and what each one is called on the wire, is knowable where reflection is — and nowhere else. So a mistake about the relationship between a type and its document is a build error rather than something you find by reading a document that came out wrong:

Refused Because
two members that are the same key one would overwrite the other reading, and both would be written
an enumeration with two enumerators of the same wire value whichever was written, only one could ever be read back
more than one fallback {} nothing says which
a serpent::describe table that does not name every enumerator the one it missed would quietly become the fallback
required {} on a member that is not an optional it is required already; the annotation says nothing
required {} and defaulted {} together they are opposites
skip {} beside key("…") a member that is not in the document has no key
as(…) or naming {…} on a data member both belong somewhere else
a field's enum_as_name / enum_as_number making two enumerators one wire value, or naming two fallbacks the same as on the enumeration, checked per field
enum_as_name / enum_as_number on a field with no enumeration in it there is nothing for it to do
more than one of with, enum_as_name, enum_as_number and tagged on one field nothing says which

Each names what it found:

error: static assertion failed: two members of this type are the same key on the wire: port.
One would overwrite the other reading, and both would be written; rename one with
serpent::key, or leave one out with serpent::skip
error: static assertion failed: this enumeration cannot be read back as it is written:
off and standby are the same value on the wire

Two of these hold without reflection, because a serpent::describe table is ordinary data: duplicate entries and a second fallback are refused wherever the table compiles. The rest need the member or enumerator list, so on a toolchain without reflection they are simply absent — the code still builds and the mistake still ships. Build once with reflection somewhere, and it is caught.

Reflection and a hand-written conversion are exclusive

Annotating a type that already has a json_convert, or a to_json / from_json pair, is a compile error rather than a silent ranking — see only one at a time.

Why opting in is deliberate

Reflecting every aggregate that merely lacks a json_convert would turn any struct that happens to be serializable into a wire-format commitment, silently. Adding a field would change the wire; so would reordering. Annotating the type makes that a decision.

Limitations

  • Acronyms convert naively: IPAddress becomes ipaddress, not ip_address. Use key("ip_address") where it matters.
  • A key is capped at 63 characters, because an annotation's type has to be structural and so stores an array rather than a pointer.
  • Only non-static data members are enumerated, in declaration order.

The naming rules themselves are ordinary constexpr code and are compiled and tested everywhere, reflection or not:

using serpent::detail::convert_case;
using serpent::naming_style;

static_assert(convert_case("mtuBytes", naming_style::snake_case).view() == "mtu_bytes");
static_assert(convert_case("mtu_bytes", naming_style::camel_case).view() == "mtuBytes");