Deserialization and std::expected: completing the round-trip
Post 8 and post 9 built one side of the round-trip: struct → JSON. This post builds the other side: JSON → struct. Same reflection walk, different direction, plus proper error handling via std::expected.
The goal
auto r = rjson::from_json<User>(
R"({"userName":"filip","id":42,"email":"filip@example.com","isAdmin":true})"
);
if (r) {
User& u = *r; // valid struct
} else {
std::println("{}", r.error()); // "expected string at .email, got number"
}
Return type: std::expected<T, parse_error>. No exceptions; the caller decides whether to propagate.
Parse strategy
Deserialization has two phases:
- Lex + parse the JSON into a generic AST (
rjson::value). Handles escapes, nesting, types. - Traverse the AST guided by reflection: for each target struct, walk its members and pull the matching key from the AST.
Phase 1 is boring-but-necessary work (~200 lines). The interesting phase is 2 — where reflection makes it trivial.
Phase 2 — reflective walker
namespace rjson {
struct parse_error {
std::string path; // JSON pointer to the offending field
std::string reason;
friend auto operator<<(std::ostream& os, parse_error const& e)
-> std::ostream& { return os << "at " << e.path << ": " << e.reason; }
};
template <typename T>
std::expected<T, parse_error> from_value(value const& v, std::string path = "");
template <typename T>
std::expected<T, parse_error> from_value_primitive(value const& v, std::string_view path) {
if constexpr (std::is_same_v<T, bool>) {
if (!v.is_bool()) return std::unexpected(parse_error{std::string{path}, "expected bool"});
return v.as_bool();
} else if constexpr (std::is_arithmetic_v<T>) {
if (!v.is_number()) return std::unexpected(parse_error{std::string{path}, "expected number"});
return static_cast<T>(v.as_number());
} else if constexpr (std::is_same_v<T, std::string>) {
if (!v.is_string()) return std::unexpected(parse_error{std::string{path}, "expected string"});
return v.as_string();
} else {
return from_value<T>(v, std::string{path}); // recurse into aggregates
}
}
template <typename T>
std::expected<T, parse_error> from_value(value const& v, std::string path) {
if (!v.is_object()) {
return std::unexpected(parse_error{path, "expected object"});
}
T result{}; // default-constructed, fields filled by reflection walk
constexpr auto ctx = std::meta::access_context::unchecked();
std::optional<parse_error> err;
template for (constexpr auto m
: std::define_static_array(
std::meta::nonstatic_data_members_of(^^T, ctx))) {
if constexpr (std::meta::annotation_of_type<skip>(m).has_value()) continue;
constexpr auto key = key_of<m>(); // annotation-aware, from post 9
auto const* slot = v.find(key);
if (!slot) {
if constexpr (!has_default_init<m>()) {
err = parse_error{path + "." + std::string{key}, "missing required field"};
return std::unexpected(*err);
}
continue; // field has a default — leave as constructed
}
using M = [: std::meta::type_of(m) :];
auto parsed = from_value_primitive<M>(*slot, path + "." + std::string{key});
if (!parsed) return std::unexpected(parsed.error());
result.[: m :] = *parsed;
}
return result;
}
template <typename T>
std::expected<T, parse_error> from_json(std::string_view src) {
auto v = parse_value(src);
if (!v) return std::unexpected(parse_error{"", "invalid JSON"});
return from_value<T>(*v);
}
} // namespace rjson
What reflection does for us
- Member enumeration.
template forover members ofT. No need to writefrom_json<User>,from_json<Address>, etc. — one template works for all. - Type dispatch.
using M = [: std::meta::type_of(m) :]splices the member’s type.from_value_primitive<M>then picks the right parsing branch viaif constexpr. - Annotation-aware keys.
key_of<m>()(from post 9) returns"userName"instead of"user_name"whenrename_all(camel_case)is set. - Defaults.
has_default_init<m>()checks if the member declaration has a default initialiser (int age = 18;); if yes, a missing key is not an error.
Error paths that are actually useful
When a JSON field mismatches, the error carries a path — "user.home.postal_code" — built by concatenating keys as the walker descends. This is what makes these errors debuggable.
auto r = rjson::from_json<User>(
R"({"userName":"filip","id":"forty","email":"..."})"
);
// → error: at .id: expected number
Hand-rolled parsers often return “parse failed” with no location. Reflection gives us the path for free, because the walker already has it as context.
std::expected vs exceptions
Both are supported C++26 patterns. We default to std::expected because:
- Deserialization is a common fallible operation, not an exceptional one. Exceptions carry signal (“this should not have happened”); parsing user input can always fail.
- Zero-cost error propagation. No stack unwinding, no RTTI hit. The return-value form is as fast as success.
- Composable.
std::expected::and_then/transformlet you chain parses cleanly.
For teams that prefer exceptions, a thin wrapper adds one:
template <typename T>
T from_json_or_throw(std::string_view src) {
auto r = from_json<T>(src);
if (!r) throw parse_exception{std::move(r.error())};
return std::move(*r);
}
Same reflective core, different surface.
Default values
struct User {
std::string name;
int age = 18; // default — missing JSON field is OK
[[=rjson::default_("unknown")]] std::string bio; // annotation-driven default
};
Two sources of defaults: the member’s default-initialiser (picked up via reflection’s has_default_member_initializer), and explicit annotations (picked up via annotation_of_type<default_>). The walker tries the former, falls back to the latter, finally errors if neither exists.
Handling unknown fields
By default, unknown JSON keys are ignored. A deny_unknown_fields container annotation (serde-compatible) flips the behaviour:
struct [[=rjson::deny_unknown_fields{}]] ApiConfig {
std::string url;
int timeout_ms;
};
// Input with extra "retry_count": 3 → parse error, "unknown field .retry_count"
Implementation detail: after the member walk, iterate the JSON object’s keys and assert each one was consumed.
Aliases
serde’s #[serde(alias = "URL")] accepts an alternate name during deserialization (and often during the migration from one spelling to another). We match that:
struct alias { char const* value; bool operator==(alias const&) const = default; };
consteval auto alias_(std::string_view s) { return alias{std::define_static_string(s)}; }
struct Legacy {
[[=rjson::alias_("URL"), =rjson::alias_("Url")]] std::string url;
};
// Accepts {"url": ...} or {"URL": ...} or {"Url": ...}
Multiple annotations on the same declaration are fine. annotations_of(m) returns all of them.
Round-tripping properly
Combined with post 8 and post 9, we have a full round trip:
static_assert(
rjson::from_json<User>(rjson::to_json(User{...})) == User{...}
);
Worth testing: test_round_trip is a one-liner in test suites now.
Performance notes
- No dynamic dispatch at runtime. Each
from_value<T>is monomorphised. - One string allocation per path segment during error construction. Happens only on error. On the happy path, no strings touched by the walker.
- Parse phase (AST) dominates cost, not the reflective dispatch. Using a SIMD JSON parser (like simdjson) as the AST phase puts us within 10-15% of hand-written parsers for flat schemas.
What’s next
- Post 11 — One codegen, many wire formats — take the same annotations and apply them across JSON, YAML, XML, TOML, MessagePack.
- After Arc 3, Arc 4 starts: CLI parsing, ORM, dependency injection, auto-mocks.