One Refactor, Three Payoffs
The previous post closed by naming two cases the validator still got wrong, and claiming both would fall out of a single refactor:
Predicates on scalar fields inside containers.
[[=Predicate{...}]] std::vector<int>doesn't validate element-wise — the current dispatch treats the annotation as applying to the container, andrequires { predicate(container) }is probably false, so it silently skips. Fixing this means splittingvalidate_implinto "walk the members of an aggregate" and "dispatch a value through the type-driven branches," so that the vector branch can send each element through the dispatch pipeline too. This is the same refactor the earlier post deferred foroptional<Scalar> + Rangeandvector<Scalar> + Range. One refactor, three payoffs.
This is that refactor. It turned out cleaner than the framing suggested — not three fixes bundled together, but three different symptoms of one misplacement. The ladder was running against the wrong value. Once it's running against the right value, the three cases just stop being cases.
The whole thing is Stage 17, one self-contained file.
The symptom, restated
Three patterns that all look natural at the annotation site and all silently do nothing:
struct User {
[[=Range{0, 150}]] std::optional<int> age; // (A)
[[=Range{0, 150}]] std::vector<int> scores; // (B)
std::vector<std::optional<Address>> past_addrs; // (C)
};(A) is the case Stage 13 deferred. (B) is what Stage 14 deferred. (C) is the case Stage 16 ran into when a user tries to put a predicate on a container element, but restated structurally without involving predicates at all: a vector of optionals of an aggregate type. Each layer has to be peeled for the inner Address to be validated.
The old walker handled none of these. Why is worth diagnosing before writing the fix.
What the old ladder did, and why it skipped
Stage 16's validate_impl iterated aggregate members. For each member, it ran an annotation ladder — one if constexpr branch per annotation type — and then a type-driven recursion for optional/vector/aggregate. Every ladder branch guarded itself with requires:
if constexpr (std::meta::type_of(ann) == ^^Range) {
if constexpr (requires { obj.[:member:] < 0LL; }
&& !is_optional_v<MT>
&& !is_vector_v<MT>) {
constexpr auto r = std::meta::extract<Range>(ann);
auto v = obj.[:member:];
if (v < r.min || v > r.max) { /* push error */ }
}
}That guard says "Range applies to a scalar-ish thing that isn't an optional or a vector." Against [[=Range{0,150}]] std::optional<int>, it's reading correctly: the outer type is std::optional<int>, which isn't scalar-comparable and is explicitly excluded by !is_optional_v<MT>. The branch silently skips. Then the recursion section peels the optional, but the recursion was written as "if the unwrapped thing is aggregate, call validate_impl again":
if constexpr (is_optional_v<MT>) {
using Inner = typename MT::value_type;
if constexpr (std::is_aggregate_v<Inner>) {
if (obj.[:member:].has_value()) {
validate_impl(*obj.[:member:], ctx);
}
}
}Inner = int is not aggregate. The branch doesn't recurse. The Range annotation never gets a chance to fire on the inner int. Same story for vector<int>: the Range branch sees the vector, skips; the recursion branch checks for aggregate elements, sees int, skips.
The bug isn't in any single branch. Each branch is locally correct. The bug is that the ladder only runs once per member, at the outermost level, and then the recursion can't re-enter the ladder because it only re-calls validate_impl (which walks members of an aggregate), not the per-value dispatch.
Stated positively: the ladder's guards are already "does this annotation apply to this value?" encoded as requires-clauses and type checks. The fix is to make the ladder runnable at any level of the walk, so that after peeling a wrapper it can run again against the inner value with the same annotations attached.
The refactor
Split the walker into two mutually recursive functions:
template <typename T>
void walk_members(const T& obj, ValidationContext& ctx);
template <std::meta::info Member, typename V>
void dispatch_value(const V& v, ValidationContext& ctx) {
// 1. Annotation ladder against v.
template for (constexpr auto ann :
std::define_static_array(
std::meta::annotations_of(Member)))
{
if constexpr (std::meta::type_of(ann) == ^^Range) {
if constexpr (requires { v < 0LL; }
&& !is_optional_v<V>
&& !is_vector_v<V>) {
constexpr auto r = std::meta::extract<Range>(ann);
if (v < r.min || v > r.max) {
ctx.errors.push_back({ /* ... */ });
}
}
}
// ... MinLength / MinSize / MaxSize / NotNullopt / Predicate branches.
}
// 2. Type-driven wrapper-piercing recursion, carrying the same Member.
if constexpr (is_optional_v<V>) {
if (v.has_value()) {
dispatch_value<Member>(*v, ctx);
}
} else if constexpr (is_vector_v<V>) {
for (std::size_t i = 0; i < v.size(); ++i) {
ctx.path_stack.push_back(i);
dispatch_value<Member>(v[i], ctx);
ctx.path_stack.pop_back();
}
} else if constexpr (std::is_aggregate_v<V>) {
walk_members(v, ctx);
}
}
template <typename T>
void walk_members(const T& obj, ValidationContext& ctx) {
template for (constexpr auto member :
std::define_static_array(
std::meta::nonstatic_data_members_of(
^^T, std::meta::access_context::unchecked())))
{
ctx.path_stack.push_back(
std::string{std::meta::identifier_of(member)});
dispatch_value<member>(obj.[:member:], ctx);
ctx.path_stack.pop_back();
}
}Two changes from Stage 16, nothing else:
- The ladder takes a value, not a member-at-its-parent-site. The guards go from
requires { obj.[:member:] < 0LL; }torequires { v < 0LL; }. Every annotation branch that used to readobj.[:member:]now readsv. No guard was rewritten or weakened. - The wrapper-piercing recursion re-enters
dispatch_value, notwalk_members. The optional branch used to only recurse into aggregate inner types; it now recurses unconditionally and lets the ladder decide again. Same for the vector branch. TheMemberstays in scope across the wrapper peeling because it's an NTTP ondispatch_value, carried down each recursive call unchanged. So when the ladder re-runs against the peeled value, it reads the same annotations it read before — just now against a differentv.
That's the whole refactor. No new annotation types, no syntax change, no new traits beyond what was there. The name cleanup is the largest surface-area change: walk_members<T> for aggregate iteration, dispatch_value<Member, V> for one value's worth of work.
Payoff 1 — Single-wrapper inheritance
struct Q1User {
[[=Range{0, 150}]] std::optional<int> age;
[[=MinLength{3}]] std::optional<std::string> nickname;
[[=Range{0, 150}]] std::vector<int> scores;
[[=MinLength{3}]] std::vector<std::string> tags;
};
Q1User u{
.age = 200,
.nickname = std::string{"al"},
.scores = {10, 200, -5},
.tags = {"ok", "hi", "fine"},
};Before the refactor, output was empty — none of the annotations fired. After:
age: must be in [0, 150], got 200 (Range)
nickname: length must be >= 3, got 2 (MinLength)
scores[1]: must be in [0, 150], got 200 (Range)
scores[2]: must be in [0, 150], got -5 (Range)
tags[0]: length must be >= 3, got 2 (MinLength)
tags[1]: length must be >= 3, got 2 (MinLength)
Walk through age specifically. dispatch_value<member_age>(optional<int>{200}, ctx):
- Ladder:
Rangeguard requiresv < 0LLand!is_optional_v<V>.V = std::optional<int>, so the guard fails, branch skips. - Recursion:
is_optional_v<V>is true,v.has_value()is true →dispatch_value<member_age>(200, ctx).- Ladder again, same annotation:
V = int, guard now passes,200 > 150→ error pushed with pathage. - Recursion:
intis not wrapper-ish or aggregate → return. The path stack was only pushed once (whenwalk_membersvisitedage). The optional unwrap doesn't push anything — it would be strange to writeage.*.value > 150in the error message. The vector unwrap does push an index, because the index is part of the path.
- Ladder again, same annotation:
scores[0] = 10 doesn't show up in the output because it passes; the ladder ran, the guard passed, 10 ∈ [0,150] is true, no error. The walker iterates every element — it just doesn't report the successful ones. Same reason tags[2] = "fine" is silent.
Q1b checks the empty-wrapper case:
Q1User u{
.age = std::nullopt,
.nickname = std::nullopt,
.scores = {},
.tags = {},
};
// → (no errors)The optional branch's has_value() check skips the entire recursion. The vector branch's for loop over v.size() == 0 does nothing. The outermost ladder didn't fire (wrapper-level guards still fail). Zero errors, which is correct: there's nothing to be in-or-out-of-range.
Payoff 2 — Nested composition
struct Address {
[[=MinLength{2}]] std::string street;
[[=Range{1, 99999}]] int zip_code;
};
struct Q2User {
std::vector<std::optional<Address>> past_addresses;
};
Q2User u{.past_addresses = {
Address{.street = "X", .zip_code = 0}, // both fail
std::nullopt, // skip
Address{.street = "OK", .zip_code = 12345}, // pass
Address{.street = "Y", .zip_code = 100000}, // both fail
}};Output:
past_addresses[0].street: length must be >= 2, got 1 (MinLength)
past_addresses[0].zip_code: must be in [1, 99999], got 0 (Range)
past_addresses[3].street: length must be >= 2, got 1 (MinLength)
past_addresses[3].zip_code: must be in [1, 99999], got 100000 (Range)
Nothing new had to be written for this case. It just works because each level of the nest hits the correct branch of the same dispatch_value:
walk_members(u, ctx)→ pushes"past_addresses"→dispatch_value<past_addresses_member>(vec, ctx).- Vector branch loops. For
i = 0, push0, calldispatch_value<past_addresses_member>(opt, ctx). - Optional branch unwraps to
Address{...}, callsdispatch_value<past_addresses_member>(addr, ctx). - Aggregate branch calls
walk_members(addr, ctx). - That walker pushes
"street", callsdispatch_value<street_member>(str, ctx); MinLength fires. - Pops
"street", pushes"zip_code", callsdispatch_value<zip_code_member>(zip, ctx); Range fires. - Pops
"zip_code". Back to theAddresswalker's end. - Pops
0. Back to the vector loop.i = 1, push1, call onnullopt. - Optional branch's
has_value()is false, branch body doesn't run. Nothing pushed. Pop1. - ... and so on.
Two wrappers, an aggregate, a walk back through its members, and two individually-annotated scalar fields, all composed from the same dispatch with no special cases for any of the combinations. The path segments pile up exactly where they should —
past_addresses, then an index, then a field name.
past_addresses[1] is nullopt, and it doesn't show up in the output at all. That's a consequence of the optional branch guarding aggregate descent on has_value(). If Q2User had annotated the optional with NotNullopt{}, the ladder would have caught the missing entry before the has_value() check and pushed an error; the two checks are independent.
Payoff 3 — Scope selection by callable signature
This is the case the refactor was really pointed at, because it's the one that isn't about recursing into wrappers — it's about demonstrating that the annotation itself travels through the recursion and decides, at each level, whether it wants to fire.
struct Q3User {
[[=Predicate{[](const std::vector<int>& v) { return !v.empty(); }},
=Predicate{[](int x) { return x > 0; }}]]
std::vector<int> positive_and_nonempty;
};Two Predicate annotations on the same field. One takes const std::vector<int>&. One takes int. Under the old walker, both would have tried to fire at the vector level, the int one would have failed its requires guard, and the vector one would have been the only thing that ever ran. Element-level checks would have silently never happened.
Under dispatch_value, both annotations are in the ladder at every level. At the vector level, V = std::vector<int>:
-
Predicate #1 (
vector<int> → bool):requires(F g) { { g(v) } -> std::same_as<bool>; }holds. Branch fires:p.f(vec)runs against the whole vector. -
Predicate #2 (
int → bool): the samerequirescheck on the lambda that takesint, called with avector<int>, fails. Branch skips. Then the vector branch peels into elements. At each element,V = int: -
Predicate #1: calling the
vector<int> → boollambda with anintfails therequires. Skips. -
Predicate #2: the
int → boollambda holds. Fires against the element. Three runs, covering the three relevant cases:
---- Q3a: empty vector — container predicate fires ----
positive_and_nonempty: custom predicate failed (Predicate)
---- Q3b: mixed — element predicate fires per-index ----
positive_and_nonempty[1]: custom predicate failed (Predicate)
positive_and_nonempty[3]: custom predicate failed (Predicate)
---- Q3c: all positive, non-empty — no errors ----
(no errors)
Q3a (empty vector): the container-level predicate is the one that can fire here, because the element loop runs zero times. And it does: !v.empty() is false, one error at the vector level.
Q3b ({3, -1, 7, 0}): the vector isn't empty, so the container predicate passes silently. The element predicate runs four times, fails twice — -1 and 0 are both non-positive — and the errors land at the indexed paths.
Q3c ({1, 2, 3}): both predicates pass at their respective levels, no errors.
What's happening structurally is that the Predicate<F> branch's inner requires guard isn't just "skip the check if this field's type doesn't match" — it's the scope selector. Same Predicate, same annotation site, two different instantiations with different Fs, and the requires clause decides at which level of the walk each one gets to run. The fix isn't new syntax; it's that the ladder runs at every level.
This is the strongest evidence that the refactor is structurally right. If it only un-skipped the deferred cases in Q1 and Q2, the test would be "did we fix these specific bugs?" Q3 is different — it's a case Stage 16 couldn't express at all, and now it works because the refactor makes the annotation-traveling-through-wrappers property observable to the user.
Coexistence
Two small sanity checks: that this doesn't break the previous stages' container-level annotations.
struct CoexistenceUser {
[[=MinSize{1}, =Range{0, 150}]] std::vector<int> required_ages;
[[=NotNullopt{}, =Range{0, 150}]] std::optional<int> required_age;
};Empty vector + nullopt optional:
required_ages: size must be >= 1, got 0 (MinSize)
required_age: must have a value (NotNullopt)
Non-empty vector with an out-of-range element, optional with an out-of-range value:
CoexistenceUser u{
.required_ages = {50, 200},
.required_age = 200,
};required_ages[1]: must be in [0, 150], got 200 (Range)
required_age: must be in [0, 150], got 200 (Range)
MinSize{1}'s guard is is_vector_v<V>, so it fires only at the container level, never at elements. Range{0,150}'s guard is the scalar-shape one, so it fires at elements (or at the unwrapped optional value), never at the container. The two live on the same ladder and don't collide because their guards are mutually exclusive at every level. Same logic for the optional side.
This is the property Stage 16 was counting on — the closed-set branches and the open Predicate branch all fire independently on every pass through the ladder — and the refactor preserves it level-by-level instead of member-by-member.
Splice wall, updated
Stage 9 hit a splice wall: a std::meta::info held in a template for loop variable couldn't be spliced into a template-argument position. Stage 12 worked around it by passing the reflection as an NTTP to a helper, where inside the function body it was no longer a loop variable. Stage 16 pushed on the boundary further: splicing a loop-var reflection into a using-alias position compiled cleanly (using F = [:targs[0]:];).
Stage 17 adds a third data point. The call dispatch_value<member>(obj.[:member:], ctx) passes a loop-variable reflection directly as an NTTP to a function template — no template <auto R> staging helper in between. It compiles and runs:
| Shape | Stage | Outcome |
|---|---|---|
extract<[:type_of(ann):]>(ann) — splice as template argument from loop var | 9 | Rejected |
foo<ann>() via helper template <auto R> — loop var as NTTP via staging | 12 | Accepted |
using F = [:targs[0]:]; — splice into using-alias from loop var | 16 | Accepted |
dispatch_value<member>(v, ctx) — loop var as NTTP directly | 17 | Accepted |
The 12-and-17 rows together say: "loop var as NTTP" works whether or not you route it through a helper. The 9-and-16 rows together say: "splice from loop var" works in using-alias position but not template-argument position.
So the wall is specifically: splicing a loop-variable-held reflection into a template-argument position. Every other combination seen so far is fine. That's a narrower read than I had after Stage 9, and it matches what you'd expect if the compiler's reservation is about template <typename T> parameters going through type-substitution during template-argument deduction, where a splice site is harder to handle than in a using-alias or NTTP.
I don't know if the wall is an implementation gap or a language restriction. The practical advice is the same either way: if you need to splice a loop-var reflection, splice it into a using-alias first, and go from there.
Where this leaves the validator
The closed-set dispatch, the open Predicate branch, the aggregate/optional/vector recursion — all of it compiles to the same dispatch function now. Adding a new annotation is one new ladder branch with a requires guard that declares its apply-site, the same as before. Adding a new wrapper type (a hypothetical std::expected<T, E> or a custom owning pointer) is one new type-driven branch in the recursion section.
Things that are no longer special cases:
- Scalar annotations on
optional<Scalar>andvector<Scalar>— Q1. - Annotated scalars nested arbitrarily deep inside containers of optionals of aggregates — Q2, but by induction any combination.
- Per-element vs per-container predicates on the same field, distinguished by callable signature — Q3. Things that are still scope cuts:
- Predicate diagnostics. Still
"custom predicate failed", hardcoded. Everything needed for richer messages — name, message, code — has a natural home as additional fields onPredicate<F>, but it's a separate stage because the diagnostics design is its own design problem. Merging it with this refactor would have produced a post where every failure was two-variable. - User-defined validator protocols.
Predicate<F>is the cheapest form of user extension: the annotation is a wrapper, the callable is the payload, the contract is fixed. A richer form would let the user write their own annotation class with avalidate(v, ctx)member, and the walker would call into it — more like a trait than a wrapper. Different axis of "opening the annotation set" from Stage 16's. Next post will probably do one of those two. The current shape of the walker makes either a one-branch addition.