Skip to content

Types and conversions

Most types need nothing written for them at all. Those that do are usually described rather than converted — an annotation, or a describe specialization for a type from someone else's header, both covered under Annotations. This page is the rest: what serpent already knows, and what to write when neither of those fits.

What works with no code at all

Arithmetic types, bool, std::string and string-likes, std::optional, std::variant, any range, any keyed container with string keys, ranges of std::byte (as binary), and enums — which go out as their underlying number unless they say otherwise. None of these need opting in, in either direction.

Describing a type is usually enough

Naming a type's fields is Annotations, whether or not you declared the type: an annotation where you can attach one, and serpent::describe<T> where you cannot. Both give the same keys, the same renaming and skipping, the same strictness, in every format and both directions.

struct [[= serpent::serializable {}]] point { int x, y; };          // a type you declared

template<> struct serpent::describe<foreign_point> {                // one you did not
    static constexpr serpent::naming naming { serpent::naming_style::snake_case };
};

Reach for a conversion below only when the body has to be yours — because the two directions disagree, because the type is not an object at all, or because your compiler has no reflection.

When the conversion has to be yours

One function, both directions

When the body needs to do more than list members:

struct segment {
    point start, end;
    std::string label;

    friend void json_convert(auto &visitor, serpent::conversion_object_t<decltype(visitor), segment> value) {
        visitor.member("start", value.start);
        visitor.member("end", value.end);
        visitor.member("label", value.label);
    }
};

visitor is reading or writing, and conversion_object_t resolves to segment & or const segment & to match. You write the field list once.

Two functions, when the directions differ

Defaults on read, keys omitted on write, a value that is a string one way and a bool the other:

struct connection {
    std::string host = "localhost";
    std::optional<int> port {};

    friend void to_json(auto &out, const connection &value) {
        const auto scope = out.object();
        scope.member("host", value.host);
        if (value.port) scope.member("port", *value.port);   // (1)!
    }

    friend bool from_json(const auto &source, connection &value) {
        if (!source.is_object()) return false;
        const connection defaults {};
        value.host = source["host"].template as<std::string>().value_or(defaults.host);
        value.port = source["port"].template as<int>();
        return true;
    }
};
  1. An empty optional writes no key at all, rather than null.

Keep the body format-blind

Say out.value(x) and let the writer decide what that means. BJData may pack a std::vector<std::uint16_t> into a typed array and copy it whole; JSON writes numbers. Neither your type nor its author needs to know.

The macro, beside the type

Names the members from outside the type, in its own namespace, for a type you cannot annotate on a toolchain that cannot reflect — where describe is unavailable for the same reason:

SERPENT_DEFINE_TYPE_NON_INTRUSIVE(point, x, y)

Specialize the serializer

Full control, including types that are not objects at all:

template<>
struct serpent::serializer<timestamp, void> {
    template<typename Writer>
    static void write(Writer &out, const timestamp &value) { out.value(value.unix_seconds()); }

    template<typename Source>
    static bool read(const Source &source, timestamp &value) {
        const auto seconds = source.template as<std::int64_t>();
        if (!seconds) return false;
        value = timestamp::from_unix(*seconds);
        return true;
    }
};

Templated or concrete, and what each costs

The example above names its writer template<typename Writer>, which is the form to reach for: it serves every format, including one added later, and a type written this way never has to be revisited. The bodies must then live in the header, as any template's must.

Naming a concrete writer instead is legitimate and buys one thing — the definitions can move into a .cpp:

// in the header
template<>
struct serpent::serializer<ip4, void> {
    static void write(serpent::bjdata::writer &out, const ip4 &value);
    static bool read(const serpent::bjdata::reader &source, ip4 &value);
};

What it costs is easy to miss: that type can now only be written to that one format. Nothing complains at the declaration; the error arrives at the first call site that writes it as something else — a log line, a status endpoint — and it reads as though the call is wrong rather than the conversion. Take the concrete form when a type is genuinely single-format and the compile-time cost of the header matters, and the templated form otherwise.

A type whose two formats need genuinely different behaviour is the remaining case, and that is what the to_json pair is for: one overload per writer, chosen by ordinary overload resolution. example/splicing.cpp does this — bytes spliced whole into BJData, transcribed into JSON.

Only one at a time

A type may carry exactly one of these forms. If it is opted in to reflection and has a hand-written conversion, that is a compile error rather than a silent ranking:

static assertion failed: this type is opted in to reflection and also has a
hand-written conversion. The hand-written one would be used and the annotation
would do nothing; remove whichever of the two you did not mean

The alternative would be worse: the annotation would sit on the type looking like it does something while the hand-written form quietly won. Migrating a type therefore means replacing one with the other in the same change, not adding the annotation and coming back later.

The one exception is a serializer<T> specialization. It replaces the dispatch entirely rather than competing inside it, so it may coexist with an annotation and it wins — which is what specializing it is for, and the only way to override a type whose definition you cannot touch.

A field that can hold one of several types

std::variant needs no tag on the wire, because a value already says what it is:

struct setting {
    std::string name;
    std::variant<bool, std::int64_t, double, std::string> value;

    friend void json_convert(auto &visitor, serpent::conversion_object_t<decltype(visitor), setting> value) {
        visitor.member("name", value.name);
        visitor.member("value", value.value);
    }
};
{"name":"threshold","value":2.5}

Writing emits whichever alternative is held. Reading asks each alternative, in declaration order, whether the value fits, and the first that accepts it wins. std::monostate is null, so std::variant<std::monostate, T> behaves like an optional that is explicitly present.

Two rules follow from "first that fits":

  • Declaration order is the tie-break. variant<double, std::int64_t> reading 4 gives the double, because a whole number fits one. Put the more specific alternative first.
  • An object must name at least one of a type's members to be read as that type. Decoding {"radius":9} into a point would otherwise succeed and leave every member at its default, which is right for a missing key but useless for telling alternatives apart. Inside a variant it is not a match.

Alternatives that are genuinely indistinguishable — two structs with the same field names — cannot be resolved this way at all. Give them a name instead, with a discriminant:

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

The alternative is then chosen by what the document calls it rather than by what parses, and a name that matches nothing fails instead of guessing.

Where the alternatives are not yours to annotate, tag the field instead — which also opts them in, so neither type needs to know serpent exists:

[[= serpent::tagged("kind")]] std::variant<point, circle> body;

Both need reflection; without it, order remains the only tie-break.

Durations and times

<serpent/chrono.hpp>, included separately so <chrono> stays out of translation units that do not want it, handles std::chrono::duration, time_point and hh_mm_ss:

struct [[= serpent::serializable {}]] sample {
    std::chrono::milliseconds elapsed;
    std::chrono::sys_time<std::chrono::milliseconds> at;
};
{"elapsed":1500,"at":1700000000000}

A duration travels as its count and nothing else. A time point is the duration since its clock's epoch, and a time of day the duration it was built from, so both follow whatever the duration does.

The unit is in the type, not on the wire

Nothing here can check that both ends named the same duration. Writing milliseconds and reading seconds gives you the same number under a different name — silently wrong by a factor of a thousand. Say which unit a field is in, in its name or in your schema, the way you would for any other bare number.

Containers

Every container shape reads back in the shape it was written, and the two directions decide by the same question so they cannot drift apart:

on the wire read back by
vector, deque, list an array growing at the back
set, unordered_set an array inserting
array, and anything else of fixed size an array assigning the slots it already has
map with text keys an object by key
map with any other key an array of two-element arrays by pairs
vector<byte>, array<byte, N> binary, where the format has it in place

A fixed-size sequence is its length: a document of another length is refused rather than filled as far as it goes, since the rest would otherwise keep whatever a default-constructed one held.

A map whose keys are not text

A document's keys are text, so a map whose key type is not travels as a sequence of two-element arrays instead:

std::map<uuid, calibration> table;     // [[key, value], [key, value], …]
std::map<std::string, int> named;      // {"x": 1}

Both sides decide by the same question — whether the key converts to a string — so a container reads back in the shape it was written. A std::pair on its own is the same two-element array, which is what makes the sequence form work for any key type that can be serialized at all.

Missing and extra keys

A member the type needs has to be in the document. Keeping its default instead is how a half-specified document passes for a whole one, and nothing downstream can tell the difference between a field that said zero and a field that said nothing.

a plain member is absent an error
a std::optional member is absent it is empty — absence is what the type represents
a member marked [[= serpent::defaulted {}]] is absent it keeps the default it declared
an optional marked [[= serpent::required {}]] is absent an error — the key must be stated, though its value may be null
a key is present that the type does not name ignored
the keys are in a different order read correctly, slightly slower

So a format that gains a field can mark it defaulted and keep reading documents written before it existed, and a type that must be fully specified gets that for nothing.

A whole type can say it, the way it says a naming rule — which is what a stored configuration usually wants, since a field added in a later version is simply absent from every file written before it, and such a file must still load rather than failing and losing every other setting with it:

struct [[= serpent::serializable {}, = serpent::defaulted {}]] user_config {
    std::string host = "localhost";
    int port = 8080;
    [[= serpent::required {}]] int version = 1;   // the exception
};

An optional is unaffected by the type-wide rule: absence is already what it represents, so only required {} on the member itself changes anything there.

struct [[= serpent::serializable {}]] calibration {
    double offset;                                   // must be there
    double scale;                                    // must be there
    [[= serpent::defaulted {}]] int revision = 1;    // added later; older documents may omit it
    std::optional<std::string> note;                 // may be absent or null
};

The check costs one bit set per member and one comparison per object — reflection knows how many members there are, so the mask is a std::uint64_t and nothing is allocated or walked twice.

A hand-written json_convert gets the same guarantee from the same rule, without saying anything extra: member() insists, and exempts a std::optional for the reason above. The member that may be left out is the one that has to be spelled differently.

friend void json_convert(auto &visitor, conversion_object_t<decltype(visitor), settings> value) {
    visitor.member("host", value.host);                   // must be there
    visitor.member_if_present("retries", value.retries);  // keeps whatever it already held
}

That way round on purpose: the strict answer is the one you get by not thinking about it, and leniency is a thing you ask for by name. A to_json/from_json pair writes its own body and is on its own, as it is for everything else.

A failed read names the member it wanted:

const auto decoded = serpent::json::try_decode<calibration>(text);
if (!decoded && decoded.error().code() == serpent::errc::missing_key) {
    log("the document has no %s", decoded.error().key());
}

Naming it costs a second walk over the document, taken only on the way to reporting an error — the generated reader knows a member was missing, not which one, and the check that told it that costs a bit set per member. decode() skips all of it and returns nothing.