cpp26-reflection · part 09

Annotations: tag-driven serialization comes to C++

· english · audience: working-cpp · discuss

Post 8’s serializer renames every C++ field to its exact identifier: user_name becomes "user_name". That’s rarely what the wire format wants.

Rust’s serde has an answer:

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct User {
    #[serde(rename = "id")] user_id: u64,
    user_name: String,
    #[serde(skip)] password_hash: String,
    #[serde(skip_serializing_if = "str::is_empty")] bio: String,
}

Java has @JsonProperty. C# has [JsonPropertyName]. Python’s Pydantic has Field(alias=...). C++ has had nothing short of macro gymnastics — until P3394 proposed user-defined annotations.

Annotation syntax

Any value usable as a non-type template argument (NTTP) can be attached to a declaration:

struct my_tag { int level; };

[[=my_tag{3}]] int critical_field;

The [[= expr ]] syntax is new. expr must be a constant expression. The annotation is attached to the declaration, queryable via reflection: std::meta::annotations_of(^^critical_field) returns a vector of info (one per annotation), and std::meta::annotation_of_type<my_tag>(^^critical_field) returns std::optional<my_tag>.

Because it’s a real C++ value — not a string or a preprocessor macro — the annotation participates in the type system. You can’t misspell it. You can’t forget an argument. The compiler catches it.

Four core serde-family annotations

namespace rjson {

// field rename
struct json_name_tag {
    char const* value;
    bool operator==(json_name_tag const&) const = default;
};
consteval auto json_name(std::string_view s) {
    return json_name_tag{std::define_static_string(s)};
}

// omit field from output
struct skip { bool operator==(skip const&) const = default; };

// omit field if value is empty (requires value.empty())
struct omit_if_empty { bool operator==(omit_if_empty const&) const = default; };

// container-level naming policy
enum class naming { as_is, snake_case, camel_case, pascal_case, kebab_case };
struct rename_all_tag {
    naming style;
    bool operator==(rename_all_tag const&) const = default;
};
consteval auto rename_all(naming s) { return rename_all_tag{s}; }

}  // namespace rjson

Two patterns here:

  1. Marker annotations — empty types used for their presence. rjson::skip{} — you check annotation_of_type<skip>(m).has_value().
  2. Parametric annotations — hold a value. But the value must be a structural NTTP type, and that restricts you: no std::string, no std::string_view. We use char const* backed by std::define_static_string (which returns a pointer to compile-time-static storage).

The factory rjson::json_name("user_name") is a consteval call that turns a string_view literal into a static-string-backed annotation — ergonomic at the call site.

Applying them

struct [[=rjson::rename_all(rjson::naming::camel_case)]] User {
                                   std::string     user_name;    // -> "userName"
    [[=rjson::json_name("id")]]    std::uint64_t   user_id;      // -> "id"
                                   std::string     email;        // -> "email"
    [[=rjson::omit_if_empty{}]]    std::string     bio;          // -> omitted if empty
    [[=rjson::skip{}]]             std::string     password_hash;// -> never emitted
                                   bool            is_admin;     // -> "isAdmin"
};

Result:

User u{"filip", 42, "filip@example.com", "hello", "hash", true};
rjson::to_json(u);
// {"userName":"filip","id":42,"email":"filip@example.com",
//  "bio":"hello","isAdmin":true}

User anon{"anon", 7, "a@b.c", "", "hash", false};
rjson::to_json(anon);
// {"userName":"anon","id":7,"email":"a@b.c","isAdmin":false}
  • user_nameuserName via rename_all.
  • user_id"id" via field-level json_name (overrides container policy — explicit beats implicit).
  • password_hash → absent entirely (compile-time pruned).
  • bio → present if non-empty (runtime check), absent if empty.

Resolving the key

template <std::meta::info Member>
consteval std::string_view key_of() {
    if constexpr (constexpr auto a
                  = std::meta::annotation_of_type<json_name_tag>(Member)) {
        return a->value;
    } else {
        constexpr auto parent = std::meta::parent_of(Member);
        if constexpr (constexpr auto pa
                      = std::meta::annotation_of_type<rename_all_tag>(parent)) {
            if constexpr (pa->style == naming::camel_case) {
                return to_camel_case(std::meta::identifier_of(Member));
            }
            // ... other naming styles
        }
        return std::meta::identifier_of(Member);
    }
}

Precedence order (standard across serde, Jackson, Pydantic): field-level rename wins, then container-level policy, then the identifier itself. All decisions at compile time via if constexpr.

Compile-time string transforms

to_camel_case is a consteval function that processes an identifier:

consteval char const* to_camel_case(std::string_view s) {
    std::string out;
    bool upper_next = false;
    for (char c : s) {
        if (c == '_') { upper_next = true; continue; }
        if (upper_next && c >= 'a' && c <= 'z')
            out += static_cast<char>(c - ('a' - 'A'));
        else
            out += c;
        upper_next = false;
    }
    return std::define_static_string(out);
}

std::string inside consteval is transient. std::define_static_string(out) promotes the result into compile-time-static storage and returns a char const* that is a valid NTTP. The JSON key "userName" ends up in your binary’s .rodata as a literal, not computed at runtime.

In the generated code

For a concrete User, rjson::to_json<User> compiles to approximately:

std::string out = "{";
out += "\"userName\":\""; append_escaped(out, obj.user_name); out += "\",";
out += "\"id\":"; out += std::to_string(obj.user_id); out += ",";
out += "\"email\":\""; append_escaped(out, obj.email); out += "\"";
if (!obj.bio.empty()) {
    out += ",\"bio\":\""; append_escaped(out, obj.bio); out += "\"";
}
// password_hash: absent — the skip branch was pruned before codegen
out += ",\"isAdmin\":"; out += obj.is_admin ? "true" : "false";
out += "}";

No annotation lookup at runtime. No reflection data in the binary. Identical to what you’d hand-write.

Comparison with other languages

LanguageRename a fieldSkip a fieldSkip-if-emptyContainer naming
Rust serde#[serde(rename="id")]#[serde(skip)]#[serde(skip_serializing_if="str::is_empty")]#[serde(rename_all="camelCase")]
Java Jackson@JsonProperty("id")@JsonIgnore@JsonInclude(NON_EMPTY)@JsonNaming(...)
C# System.Text.Json[JsonPropertyName("id")][JsonIgnore]JsonIgnoreCondition.WhenWritingDefaultNaming policy (runtime config)
Go encoding/jsonjson:"id"json:"-"json:",omitempty"— (tag per field)
Pydantic (Py)Field(alias="id")Field(exclude=True)(custom validator)model_config(alias_generator=...)
C++ rjson[[=json_name("id")]][[=skip{}]][[=omit_if_empty{}]]struct [[=rename_all(camel_case)]]

The vocabulary ports cleanly. The mechanics are different — Rust and C++ make all decisions at compile time, Java/C#/Python do most of the work at runtime via walked attribute tables. In Rust the derive macro sees your struct’s syntax tree; in C++ the reflection walker sees the real type. Neither pays runtime cost for annotation lookup.

Non-serialization uses

Annotations are a language feature, not a JSON feature. They’re equally useful for:

  • ORM column mapping (post 13): [[=sql::column("user_id")]].
  • CLI flags (post 12): [[=cli::short('v')]].
  • Validation: [[=validate::range(0, 100)]].
  • Documentation generation: [[=docs::deprecated("Use foo() instead.")]].

Any value with an operator== and structural NTTP shape works. The pattern is universal.

What’s next