Skip to content

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

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

clockblocker wants to merge 2 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.

}
};

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.

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.

@liuzicheng1987

Copy link
Copy Markdown
Collaborator

@clockblocker you should see the minor comments now

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