cpp26-reflection · part 10

Deserialization and std::expected: completing the round-trip

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak· discuss

Correction, 6 October 2026. The Performance notes at the end say that using a SIMD JSON parser such as simdjson for the first phase puts this approach within 10-15% of hand-written parsers for flat schemas. I have no measurement behind that figure, and there is nothing here to measure it with: the Compiler Explorer demo at the end has no lexer and no parser. It fills the struct from a std::map of already-parsed values, and its own header calls it a scaled-down demo. The parser of phase 1 is not shown in this post either, and the snippets call a parse_value that the post never defines. I removed the figure and the claim that the parse phase dominates the cost, because neither was measured. I also fixed the sample error message in the first snippet, which the code does not print, and the note that the walker touches no strings on the happy path, because it builds a path string for every field it reads. The text below is otherwise left as published, so you can read it against this note.

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());  // at .email: expected string
}

Return type: std::expected<T, parse_error>. No exceptions; the caller decides whether to propagate.

Parse strategy

Deserialization has two phases:

  1. Lex + parse the JSON into a generic AST (rjson::value). Handles escapes, nesting, types.
  2. 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: the 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

  1. Member enumeration. template for over members of T. No need to write from_json<User>, from_json<Address>, etc. One template works for all.
  2. Type dispatch. using M = [: std::meta::type_of(m) :] splices the member’s type. from_value_primitive<M> then picks the right parsing branch via if constexpr.
  3. Annotation-aware keys. key_of<m>() (from post 9) returns "userName" instead of "user_name" when rename_all(camel_case) is set.
  4. 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 / transform let 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.
  • The walker builds the path string (path + "." + key) for every field it reads, on the happy path as well as on error. I did not measure what that costs.
  • This post has no measurement of the parse phase, and the demo has no parser. It makes no claim about how the cost compares with a hand-written parser.

Where the series goes next