Annotations: tag-driven serialization comes to C++
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:
- Marker annotations — empty types used for their presence.
rjson::skip{}— you checkannotation_of_type<skip>(m).has_value(). - Parametric annotations — hold a value. But the value must be a structural NTTP type, and that restricts you: no
std::string, nostd::string_view. We usechar const*backed bystd::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_name→userNameviarename_all.user_id→"id"via field-leveljson_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
| Language | Rename a field | Skip a field | Skip-if-empty | Container 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.WhenWritingDefault | Naming policy (runtime config) |
| Go encoding/json | json:"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
- Post 10 — Deserialization +
std::expected— parse JSON into a typed struct with proper error paths. - Post 11 — One codegen, many wire formats — the same annotations applied across JSON, YAML, XML.