Skip to content

Expose the location of each parse failure via rfl::Error::details() - #766

Open
clockblocker wants to merge 4 commits into
munich-data-engineering:mainfrom
clockblocker:structured-error-details
Open

clockblocker wants to merge 4 commits into
munich-data-engineering:mainfrom
clockblocker:structured-error-details

Conversation

@clockblocker

@clockblocker clockblocker commented Nov 3, 2026 •

Copy link
Copy Markdown

Why

I'm using the library in the production project to establish a source of truth for the chain:

Core Process (C++ ) 
	->  Web Server (Ts + Effect) -> React (Ts + Effect) ->  React Hook Form (Effect Schema as resolver)
	->  CLI Process (C++)
	->  Native App (C++, Qt)

Part of the app is a complex form with tricky (yet expressible in Standard Schema) validation rules.

Would like to express the {message, path} in the source-of-truth struct, so that all the clients can expect to send messages like:

// Input: 
{ 
	"params": {
		"absolutePath": "123" 
	} 
}

and receive errors like:

// Error: 
{ 
	message: "File does not exist",
	path: "params/absolutePath",
}

AI-authored

Context

TypeScript validation libraries such as Zod, Valibot, ArkType and Effect Schema report failures in the Standard Schema shape: a list of issues, each with a message and a path. A C++ backend that validates with reflect-cpp can't produce that list today. what() is a single prose string, so a client can't tell which form field to highlight unless it parses the message text.

details() maps directly onto Standard Schema issues. Each ErrorDetail becomes one issue, field names become string keys, and indices become numbers. A web client can then show backend validation errors the same way it shows its own.

Summary

Closes #765.

rfl::Error keeps what() exactly as it is, and adds details(): one entry per failure, each with the path where it occurred.

struct ErrorDetail {
  using PathSegment = std::variant<std::string, size_t>;
  std::vector<PathSegment> path;  // field names (after processors) and indices
  std::string message;            // leaf message, without "Failed to parse field ..." prefixes
};

std::vector<ErrorDetail> Error::details() const&;  // also an && overload

An error that was built from a message alone reports itself as one detail with an empty path. Errors from custom parsers and validators therefore need no changes.

Each reader adds a path segment at the point where it already prefixes the message:

 ViewReader / MapReader             "Failed to parse field 'x': ..."  + segment "x"
 NamedTupleParser                   "Field named 'x' not found."      + segment "x"
 ViewReader (NoExtraFields)         "Value named 'x' not used."       + segment "x"
 ArrayReader / TupleReader          "Failed to parse element/field i" + segment i
+VectorReader                       (message unchanged)               + segment i
 ParserTaggedUnion                  prefix only; inner paths are kept
 to_single_error (new)              joins messages as before, concatenates details

Untagged variants stay a single failure at the variant's location, because reflect-cpp can't tell which alternative the input was meant to be. The joined what() still shows at most 10 errors, but details() contains all of them.

FieldErrors and MapReader now collect Errors instead of strings. The new helpers live in parsing/wrap_error.hpp. Docs are in docs/content/result.md under "Locating errors".

Evidence

The issue's example, via rfl::cli::read (tests/cli/test_errors.cpp):

$ program --files.input-file=abcd --retries=1,two

what() (unchanged):
Found 2 errors:
1) Failed to parse field 'files': Found 2 errors:
    1) Failed to parse field 'input-file': Size validation failed: Value expected to be greater than or equal to 5, but got 4.
    2) Field named 'output-file' not found.
2) Failed to parse field 'retries': Could not cast 'two' to integer for key ''.

details() (new):
[files, input-file]   Size validation failed: Value expected to be greater than or equal to 5, but got 4.
[files, output-file]  Field named 'output-file' not found.
[retries, 1]          Could not cast 'two' to integer for key ''.

The index 1 in retries exists only in details(). Today's what() doesn't say which element failed.

tests/json/test_error_details.cpp covers nested structs in vectors with SnakeCaseToCamelCase, maps, std::array, tuples, sets with duplicates, NoExtraFields, FailFast, DefaultIfMissing, tagged unions, untagged variants, and root-level errors. Existing tests that compare what() exactly still pass, which shows the messages are unchanged.

Local runs (Apple clang 17, REFLECTCPP_USE_VCPKG=OFF): JSON 273, CLI 72, generic 98, env 41, json_c_arrays_and_inheritance 7, all passing. The new tests plus test_error_messages, test_fail_fast and test_tagged_union also pass with REFLECTCPP_USE_STD_EXPECTED / C++23. I couldn't build the other formats locally, so CI will be their first run. They share the changed parsing code, and no format-specific code uses the changed internals.

Performance. Benchmark: read a std::vector of 100k nested structs from JSON, 40 runs, median of interleaved rounds.

  • Before: 34.55 ms
  • After: 34.84 ms (+0.8%, close to run-to-run noise)

My first version stored the details as a plain std::vector member and cost +8.5%. Bisecting showed the cause: ~Error() is inlined into every Result<T> destructor, and ~vector<ErrorDetail> is a lot of code. So the details now sit behind a unique_ptr whose deleter and copy are out of line, using a new RFL_COLD macro (noinline + cold). The error-wrapping helpers use it too. sizeof(rfl::Error) grows by 8 bytes (24 → 32 on libc++). The vector index comes from size() instead of a per-element counter; maps and sets keep a counter because duplicates don't grow size().

Merge Danger

Door: two-way. The API is purely additive and nothing is persisted.

Blast Radius: small. Two things could affect downstream code:

  • Code that relies on sizeof(rfl::Error) or on its copy being trivially defaulted (unlikely).
  • Code that calls rfl::parsing::MapReader directly or depends on FieldErrors being std::vector<std::string>. Both are internal.

Not included: the optional "kind" field (missing / extra / validation / wrong type) from the issue. I can add it here or in a follow-up if you want it.

🤖 Generated with Claude Code

rfl::Error keeps its message, but now also carries the individual
failures it summarizes, each with the path of field names and array
indices at which it occurred. The readers that prefix nested messages
with "Failed to parse field ..." record a path segment at the same
place, so programs no longer need to parse what() to find out which
field failed.

Closes munich-data-engineering#765.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clockblocker

Copy link
Copy Markdown
Author

A note on why ErrorDetail::PathSegment is a std::variant<std::string, size_t> rather than an rfl::Variant, even though the docs recommend rfl::Variant:

  • rfl::Variant is recommended mainly for compile times, "particularly for variants with many alternatives". A path segment has two alternatives, so the gain would be negligible.
  • internal/Error.hpp reaches almost every translation unit through Result.hpp. Depending only on the standard library keeps it light and avoids pulling rfl/Variant.hpp and its internals into all of them.
  • It matches rfl::Generic, whose public VariantType is a std::variant too, and users can read a segment with plain std::visit or std::get_if.

If you'd prefer rfl::Variant here, it's a one-line type change plus the visits in the tests and docs. I'm happy to switch.

@liuzicheng1987

Copy link
Copy Markdown
Collaborator

A note on why ErrorDetail::PathSegment is a std::variant<std::string, size_t> rather than an rfl::Variant, even though the docs recommend rfl::Variant:

  • rfl::Variant is recommended mainly for compile times, "particularly for variants with many alternatives". A path segment has two alternatives, so the gain would be negligible.
  • internal/Error.hpp reaches almost every translation unit through Result.hpp. Depending only on the standard library keeps it light and avoids pulling rfl/Variant.hpp and its internals into all of them.
  • It matches rfl::Generic, whose public VariantType is a std::variant too, and users can read a segment with plain std::visit or std::get_if.

If you'd prefer rfl::Variant here, it's a one-line type change plus the visits in the tests and docs. I'm happy to switch.

std::variant is fine.

@liuzicheng1987

Copy link
Copy Markdown
Collaborator

@clockblocker the PR looks great. Thanks for the initiative. I have minor comments (see above). Also, the build fails on at least some of the compiler settings. Could you take a look?

Clang 17 does not support floating-point non-type template arguments,
so rfl::Minimum<0.0> fails to compile in the JSON-C++20-llvm-17 job.
An integer bound tests the same thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clockblocker

clockblocker commented Nov 5, 2026 •

Copy link
Copy Markdown
Author

Thank you for the review.

Don't see any "Minor comments" beyond the failing build, though.


Ai-authored:

Build failure: fixed in 0cc1fcd. The new test used rfl::Validator<double, rfl::Minimum<0.0>>, and Clang 17 doesn't support floating-point non-type template arguments yet, so JSON-C++20-llvm-17 failed. It now uses an int bound, which tests the same thing. That was the only failing job so far.

Comment thread include/rfl/internal/Error.hpp Outdated
}
};

using DetailsPtr = std::unique_ptr<std::vector<ErrorDetail>, DeleteDetails>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clockblocker Could you use rfl::Box here? Clearly, the implicit assumption here is that DetailsPtr should not be NULL, so rfl::Box establishes that more clearly.

Moreover, if you use rfl::Box, you won't have to implement the copy operations by hand.

@clockblocker clockblocker Nov 6, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay. This turned out to be less straightforward than it seemed.

While the end user sees both path and message as non-nullable, internally most
errors have neither.

Jarvis'es benchmarks got worse after the rfl::Box switch. He explained it like this:


// AI-Authored:

An error built from a message alone carries no details, and details() derives one on the fly
(an empty path plus the message).

So "no details" is the common case, and storing it as nothing keeps plain errors allocation-free and
rfl::Result small.

rfl::Box can't express that: it is never empty, so every error would allocate, and successful reads got 6% slower.


I built and benchmarked the rfl::Box version.

It had issues:

  • Every error allocates. A Box is never empty, so even a plain rfl::error("..."), which has no details, gets a heap-allocated vector. Copying a plain error became 9.6× / 2.5× slower, with an extra allocation.
  • Successful reads got 6% slower on both compilers.
  • It can't be included as is. Box.hpp includes Result.hpp, which includes internal/Error.hpp, so I had to split Box.hpp in two.
  • A moved-from Error breaks. A moved-from Box is empty, so calling details() on that error or copying it dereferences a null pointer.

Then I prototyped the std::vector and std::shared_ptr versions. Each had tradeoffs:

  • Inline std::vector is the simplest code, but Error grows by 16 bytes. Successful reads get 3–5% slower, and on GCC failures get up to 41% slower.
  • std::shared_ptr makes copying an error with details about 9× cheaper, but Error grows by 8 bytes, and most failure cases get 10–27% slower, because details() && has to copy the shared details at every level of nesting.

Finally, I settled on Error::OptionalDetails (a038bc0), a small private type that holds either nothing or a heap-allocated vector, and deep-copies it:

  • Its name and has_value() make it explicit that the details can be empty, so that is no longer an implicit assumption.
  • Error's special members are = default again, exactly as on main, so Error has no hand-written copy operations.
  • It costs nothing measurable: Error keeps its size, and the timings match the previous version within noise.

Measurements: each cell shows Clang on the first line and GCC on the second. The first column is the previous version; the others show the change relative to it.

Clang /
GCC
Previous version (unique_ptr) OptionalDetails (now in this PR) inline std::vector rfl::CopyableBox std::shared_ptr
Successful read, 2,000 objects 687 µs /
929 µs
$\color{green}{\textbf{±0 \% /}}$
$\color{green}{\textbf{−2 \%}}$
+3 % /
+5 %
+6 % /
+6 %
+2 % /
+1 %
Untagged variant, 3rd alternative matches 1232 ns /
1386 ns
+1 % /
±0 %
+3 % /
−1 %
+2 % /
+3 %
+10 % /
+2 %
Validator failure 3832 ns /
5648 ns
−9 % /
±0 %
$\color{green}{\textbf{−11 \% /}}$
$\color{red}{\textbf{+41 \%}}$
−9 % /
+21 %
−12 % /
+10 %
Failure 2 arrays deep 666 ns /
896 ns
−3 % /
±0 %
−7 % /
−7 %
+16 % /
+4 %
$\color{red}{\textbf{+27 \% /}}$
$\color{red}{\textbf{+15 \%}}$
4 nested failures 5974 ns /
8294 ns
±0 % /
±0 %
$\color{green}{\textbf{−3 \% /}}$
$\color{red}{\textbf{+26 \%}}$
+2 % /
+14 %
+12 % /
+17 %
Copy an error with details 261 ns /
304 ns
±0 % /
+3 %
−19 % /
−16 %
−2 % /
+1 %
$\color{green}{\textbf{−88 \% /}}$
$\color{green}{\textbf{−90 \%}}$
Copy a plain error 2.1 ns /
26 ns
±0 % /
+7 %
±0 % /
−2 %
$\color{red}{\textbf{9.6 × /}}$
$\color{red}{\textbf{2.5 ×}}$
$\color{red}{\textbf{+1 alloc}}$
−38 % /
−6 %
sizeof(Error) 32 bytes /
40 bytes
32 bytes /
40 bytes
48 bytes /
56 bytes
32 bytes /
40 bytes
40 bytes /
48 bytes

How I measured: Apple Clang 17 with libc++ and GCC 16 with libstdc++, both -O3, on an arm64 Mac, best of 8 interleaved runs. All five versions include 27ebb4d (below).

Two more things came out of this:

  • 27ebb4d, "Move errors out of failed reads instead of copying them": the parsers kept the errors they pass upward in const locals and returned them with error(*err) or return err;, which copies them. That got expensive once errors carried details. For example, reading a std::variant<Circle, Rectangle, Square> that matches the third alternative went from 1046 ns and 17 allocations on main to 1676 ns and 31 allocations. With the fix it takes 1232 ns and 21 allocations (Clang). The commit adds error(Error&&) and touches each format's Reader, but only to drop const from a local that is returned.

  • rfl::CopyableBox can't be copy-assigned today. Its copy assignment operator takes const Box<T>&, which is the non-copyable Box, so the real copy assignment is implicitly deleted: b = a; with two CopyableBox<int> doesn't compile on main. A separate PR for that: Make rfl::CopyableBox copy-assignable #769

Comment thread include/rfl/internal/Error.hpp Outdated
using DetailsPtr = std::unique_ptr<std::vector<ErrorDetail>, DeleteDetails>;

RFL_COLD static DetailsPtr copy_details(const DetailsPtr& _ptr) {
return _ptr ? DetailsPtr(new std::vector<ErrorDetail>(*_ptr)) : nullptr;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I mean...rfl::Box can handle that more elegantly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ai-authored:

copy_details is gone in a038bc0: the deep copy now lives in Error::OptionalDetails, and Error's copy operations are = default again. The reasons and measurements are in the thread above.

@liuzicheng1987

Copy link
Copy Markdown
Collaborator

@clockblocker you should see the minor comments now

clockblocker and others added 2 commits November 6, 2026 08:19
The parsers kept the errors they propagate in const locals, such as
`const auto [set, err] = ...` or `const auto err = ...`, and passed them
on with `error(*err)` or `return err;`. Both copy the error: rfl::error
only accepted a const Error&, and a const local cannot be moved from.

Copying an error used to copy one string. Now that errors carry their
details, it also copies every path and message, at every level the
error passes through. Reading an untagged variant whose first
alternatives fail became about 60% slower than before details existed.

Add an rfl::error overload that takes an Error&&, and move the errors
instead of copying them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Error held its details in a unique_ptr with a cold deleter and defined
its copy operations by hand to deep-copy them. A null pointer meant
"this error is just its message", which a reader had to infer from a
comment.

The pointer now lives in Error::OptionalDetails, a small private type
that holds either nothing or a heap-allocated vector, and deep-copies
it. Its name and has_value() make the empty state explicit. Because it
is copyable, Error's special members are defaulted again, exactly as
they were before details existed.

Runtime behaviour does not change: errors that are just their message
do not allocate, the member is still a single pointer, and deleting and
copying the details stay out of line (RFL_COLD).

rfl::Box does not fit here: it is never empty, so every error would
allocate, and Box.hpp includes Result.hpp, which includes this header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Structured parse errors: expose each failure's field path, not only a joined message

2 participants