RICCILAB
> blog/cpp26/from-stage-to-library

From Stage to Library

_DEV_C++26

The previous post ended with five “plausibly-next” items. This is the one I was most honest with myself about:

include/validator.hpp migration. The header-only release is still at Stage 8’s closed-set shape. Moving the Stage 18 protocol into it is mostly mechanical — every annotation gains a validate member, the dispatch collapses, the entry points don’t change. Worth doing before the next stage cares about it.

“Mostly mechanical” was half true. The dispatch side really was a copy: collapse the six-branch ladder into the single requires { a.validate(v, ctx); } probe and move on. But the header isn’t just Stage 18 in a file — it also has to be Stage 14’s indexed path stack, Stage 17’s wrapper-piercing recursion, and Stage 7’s CollectAll / FailFast policy layer, reconciled into one walker. Stage 18 used the first two without re-explaining them; the third wasn’t in the stage file at all.

This post is the migration commit, with the wrinkles that showed up.


Where the header was stuck

include/validator.hpp had been frozen at the Stage 8 shape since the first walkthrough post. A quick reminder of that shape:

// Stage 8 era.
namespace av {
    struct Range     { long long min, max;};
    struct MinLength { std::size_t value;};
    struct MaxLength { std::size_t value;};
    struct NotEmpty  {};
 
    namespace detail {
        struct ValidationContext {
            std::vector<ValidationError>  errors;
            std::vector<std::string>      path_stack;   // flat
            Mode                          mode = Mode::CollectAll;
            bool should_stop() const;
            std::string current_path() const;           // dotted join
        };
 
        template <typename T>
        void validate_impl(const T& obj, ValidationContext& ctx) {
            template for (constexpr auto member : /* members of T */) {
                if (!ctx.should_stop()) {
                    ctx.path_stack.push_back(/* name */);
 
                    template for (constexpr auto ann : /* annotations */) {
                        if (!ctx.should_stop()) {
                            if constexpr (type_of(ann) == ^^Range)     { /*…*/ }
                            else if constexpr (type_of(ann) == ^^MinLength) { /*…*/ }
                            else if constexpr (type_of(ann) == ^^MaxLength) { /*…*/ }
                            else if constexpr (type_of(ann) == ^^NotEmpty)  { /*…*/ }
                        }
                    }
 
                    using MT = std::remove_cvref_t<decltype(obj.[:member:])>;
                    if constexpr (std::is_aggregate_v<MT>) {
                        if (!ctx.should_stop()) validate_impl(obj.[:member:], ctx);
                    }
 
                    ctx.path_stack.pop_back();
                }
            }
        }
    }
 
    template <typename T> std::vector<ValidationError>            collect (const T&, Mode);
    template <typename T> std::expected<void, std::vector<>>     check   (const T&, Mode);
    template <typename T> void                                    validate(const T&, Mode);
}

Four annotations, one closed-set ladder, aggregate-only recursion, a flat vector<string> path stack, and should_stop() gates at three boundaries (between members, between annotations, before aggregate recursion). Everything else — std::optional, std::vector, indexed paths, container-level MinSize / MaxSize, Predicate, the whole Stage 16→17→18 arc — lived in standalone stage files and had never touched the header.

Where Stage 18 had gone

Stage 18 in its single-file form was much shorter than the header-ported version needs to be:

// Stage 18 — no policy layer.
struct ValidationContext {
    std::vector<ValidationError>              errors;
    std::vector<std::variant<std::string,
                             std::size_t>>    path_stack;  // variant
    std::string current_path() const;
    // no mode, no should_stop()
};
 
template <std::meta::info Member, typename V>
void dispatch_value(const V& v, ValidationContext& ctx) {
    template for (constexpr auto ann : /* annotations */) {
        using A = [:type_of(ann):];
        constexpr auto a = extract<A>(ann);
        if constexpr (requires { a.validate(v, ctx); }) {
            a.validate(v, ctx);
        }
    }
 
    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 : /* members of T */) {
        ctx.path_stack.push_back(std::string{identifier_of(member)});
        dispatch_value<member>(obj.[:member:], ctx);
        ctx.path_stack.pop_back();
    }
}

No should_stop(). No Mode. The stage file didn’t need either — it just dumped every error. The policy layer is a header concern, and the header is what we’re building.

Three threads, one walker

The migration has to thread three changes through the same walker:

  1. The protocol (Stage 18). Annotation dispatch collapses from a four-branch type-switch to a single requires { a.validate(v, ctx); }. Every built-in annotation gains a validate(const V&, Ctx&) template member.
  2. Wrapper recursion (Stage 17). The walker splits into walk_members<T> (iterate members) and dispatch_value<Member, V> (annotation ladder + wrapper peel). The ladder runs at every level of the walk, so annotations on a vector<int> field fire both at the container level and at each element, with the requires-guard inside each annotation picking the scope.
  3. Indexed paths (Stage 14). path_stack moves from vector<string> to vector<variant<string, size_t>>. Field segments push a string, container indices push a size_t, and current_path() renders them as name and [N] with dotted joins only between string segments. And underneath all three: the CollectAll / FailFast policy has to keep working.

The FailFast wrinkle

Commit: 7ce8084

The stage-file walker ran top to bottom with no short-circuit. The header walker has to gate on ctx.should_stop() at every boundary — otherwise FailFast keeps accumulating errors past the first one. There are three boundaries, not two:

template <typename T>
void walk_members(const T& obj, ValidationContext& ctx) {
    template for (constexpr auto member : /* members */) {
        if (!ctx.should_stop()) {                   // ← 1. between members
            ctx.path_stack.push_back(/* name */);
            dispatch_value<member>(obj.[:member:], ctx);
            ctx.path_stack.pop_back();
        }
    }
}
 
template <std::meta::info Member, typename V>
void dispatch_value(const V& v, ValidationContext& ctx) {
    template for (constexpr auto ann : /* annotations */) {
        if (!ctx.should_stop()) {                   // ← 2. between annotations
            using A = [:type_of(ann):];
            constexpr auto a = extract<A>(ann);
            if constexpr (requires { a.validate(v, ctx); }) {
                a.validate(v, ctx);
            }
        }
    }
 
    if constexpr (is_optional_v<V>) {
        if (v.has_value() && !ctx.should_stop()) {   // ← 3a. before optional unwrap
            dispatch_value<Member>(*v, ctx);
        }
    } else if constexpr (is_vector_v<V>) {
        for (std::size_t i = 0; i < v.size(); ++i) {
            if (ctx.should_stop()) break;           // ← 3b. between elements
            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>) {
        if (!ctx.should_stop()) {                   // ← 3c. before descent
            walk_members(v, ctx);
        }
    }
}

The Stage 8 walker had only boundaries 1 and 2 plus a pre-aggregate-descent check. Wrapper recursion adds the optional unwrap and the per-element loop as new places a FailFast short-circuit has to be honored. Miss one and collect(bad, FailFast) returns more than one error, silently violating policy.

One subtlety: template for doesn’t have break. The iterations are physically expanded at compile time, so each gets a runtime if (!ctx.should_stop()) wrapper instead of a loop break. Unreached iterations are still expanded and compiled, they just do nothing at runtime. The compile-time cost stays the same; only the runtime short-circuits.

Namespace layout

Stage 18 had everything at file scope. The header splits:

  • av::Range, MinLength, MaxLength, NotEmpty, MinSize, MaxSize, NotNullopt, Predicate<F, N>, ValidationError, ValidationException, Mode, collect, check, validate, format_error
  • av::detail::is_optional_v, is_vector_v, PathSegment, ValidationContext, walk_members, dispatch_value The annotations go in av:: because users attach them with [[=av::Range{…}]]. The walker and traits go in av::detail:: because they aren’t meant to be called directly.

There’s one cross-namespace reference that worried me and turned out to be fine: annotations’ validate() bodies call ctx.current_path() and push into ctx.errors, where ctx is an av::detail::ValidationContext&. But the Ctx parameter is a template parameter — annotations don’t name av::detail::ValidationContext directly. They only require that whatever context they get has errors.push_back(...) and current_path() members. Structurally typed from the annotation’s side. The detail namespace stays opaque.

Two annotations Stage 18 didn’t carry

Stage 18’s annotation set was Range, MinLength, MinSize, MaxSize, NotNullopt, Predicate. The header also had MaxLength and NotEmpty from Stage 8, and those are part of the library’s public API contract — I couldn’t drop them.

Both port forward as one-liners, structurally identical to MinLength:

struct MaxLength {
    std::size_t value;
    constexpr MaxLength(std::size_t v) : value(v) {}
 
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const {
        if constexpr (requires { v.size(); }
                   && !detail::is_optional_v<V>
                   && !detail::is_vector_v<V>) {
            if (v.size() > value) {
                ctx.errors.push_back({
                    ctx.current_path(),
                    std::format("length must be <={}, got{}", value, v.size()),
                    "MaxLength"
                });
            }
        }
    }
};
 
struct NotEmpty {
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const {
        if constexpr (requires { v.empty(); }
                   && !detail::is_optional_v<V>) {
            if (v.empty()) {
                ctx.errors.push_back({
                    ctx.current_path(),
                    "must not be empty",
                    "NotEmpty"
                });
            }
        }
    }
};

The !is_optional_v<V> guard on NotEmpty is deliberate — std::optional doesn’t have .empty(), so the requires { v.empty(); } clause would already skip it on the optional level, but being explicit makes the intent readable. (On a vector<int>, NotEmpty will fire at the container level — vector::empty() is a thing — and that’s useful. On the inner int, the requires-guard silently fails and nothing happens.)

Two annotations, eight lines of logic each, protocol conformance obtained by having a validate member. The closed-set ladder would have made this a migration: “add two more else if branches in the dispatch.” The protocol makes it zero work in the walker.

Testing: regression first, then new surface

Commit: 7ce8084

The migration preserves the Stage 8 API exactly. Existing tests/smoke_test.cpp asserts the old behavior:

struct User {
    [[=av::Range{0,150}]]                       int         age;
    [[=av::MinLength{3}]] [[=av::MaxLength{32}]] std::string name;
    [[=av::NotEmpty{}]]                          std::string email;
    Address                                                  address;
};
 
// bad = User{200, "al", "", {"X", 0}};
// Expected: 5 errors, in the exact order:
//   age, name, email, address.street, address.zip_code

Running after the migration: smoke test: OK. No assertions touched.

Then a second smoke test, tests/protocol_smoke_test.cpp, for the new surface. It exercises:

  • optional<string> with NotNullopt at the outer level and MinLength / StartsWithUppercase at the inner level (the latter is a user-defined protocol annotation — a struct with a validate() member, defined in the test file, unknown to the library)
  • vector<int> with MinSize / MaxSize at the container level and element-level Range
  • Predicate with the default message (CTAD guide Predicate(F) -> Predicate<F, 24>) and with a literal-sized custom message (CTAD guide Predicate(F, const char (&)[N]) -> Predicate<F, N>)
  • vector<Address> — nested aggregate traversal producing indexed dotted paths like past_addresses[0].street A representative chunk:
// User-defined — not known to the library header.
struct StartsWithUppercase {
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const {
        if constexpr (requires { v.empty(); v[0]; }) {
            if (v.empty() || v[0] < 'A' || v[0] > 'Z') {
                ctx.errors.push_back({
                    ctx.current_path(),
                    "must start with an uppercase letter",
                    "StartsWithUppercase"
                });
            }
        }
    }
};
 
struct Profile {
    [[=av::NotNullopt{},=StartsWithUppercase{},=av::MinLength{3}]]
    std::optional<std::string> nickname;
 
    [[=av::MinSize{1},=av::MaxSize{3},=av::Range{0,100}]]
    std::vector<int> scores;
 
    [[=av::Predicate{[](int x){ return x%2==0;}}]]                  int even_field;
    [[=av::Predicate{[](int x){ return x>0;},"count must be positive"}]] int count;
 
    std::vector<Address> past_addresses;
};

For a bad profile with nickname = std::nullopt, scores = {150, -5, 200, 300, 500}, even_field = 3, count = -1, past_addresses containing one bad Address, the collected errors include:

  • nickname: must have a value (NotNullopt) — outer-level on the optional; no inner recursion because has_value() is false, so MinLength and StartsWithUppercase don’t fire
  • scores: size must be <= 3, got 5 (MaxSize) — container-level
  • scores[0]scores[4]: must be in [0, 100], got … (Range) — element-level, per element
  • even_field: custom predicate failed (Predicate) — default message from the N = 24 guide
  • count: count must be positive (Predicate) — custom message from the N = 23 literal-sized guide
  • past_addresses[0].street: length must be >= 2, got 1 (MinLength) — indexed path into the nested aggregate
  • past_addresses[0].zip_code: must be in [1, 99999], got 0 (Range) — same protocol smoke test: OK. Every single-file stage demo since Stage 13 now runs through <validator.hpp>.

What users see

The API table in the README grew, not changed:

AnnotationWhat it does
Range, MinLength, MaxLength, NotEmptyScalar / string — unchanged from Stage 8
MinSize, MaxSizeContainer size bounds for std::vector<T>
NotNulloptAsserts std::optional<T> has a value
Predicate<F, N>User-callable + char[N] message

And a new row that isn’t really an annotation at all:

Any struct with constexpr template <V, Ctx> void validate(const V&, Ctx&) | Rides the same dispatch as the built-ins |

That last row is the whole point of the migration. A user who #include <validator.hpp> can now add a new validator by writing eight lines in their own header:

struct MyRule {
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const {
        if constexpr (/* applicability guard */) {
            if (/* violates */) ctx.errors.push_back({ctx.current_path(), "", "MyRule"});
        }
    }
};

The library header doesn’t need a patch. The std::format strings in each built-in’s validate body are the only places that know annotation names, and users can follow the same convention — or not.

What didn’t move

  • The three entry points. collect, check, validate are still the same thin wrappers over the context they were in Stage 7. No signature change. They now route through walk_members instead of validate_impl, but the function names in av:: are unchanged.
  • ValidationError. { path, message, annotation }. Same three strings. format_error still produces "<path>: <message> (<annotation>)".
  • ValidationException. Same class, same errors member, same what() message (“validation failed with N error(s)”). No catch-site migration for users who used it.
  • Default Mode. CollectAll. Opt-in to FailFast with the second argument. Same as Stage 7. The Stage 8 smoke_test.cpp running without changes is the proof — the header change is additive from the outside.

“Mostly mechanical” — reviewed

The original judgment from the Stage 18 closing:

Moving the Stage 18 protocol into it is mostly mechanical — every annotation gains a validate member, the dispatch collapses, the entry points don’t change.

Two-thirds right.

  • Every annotation gains a validate member. Actual. The four old annotations plus MinSize / MaxSize / NotNullopt port in about eight lines of logic each, structurally identical to each other.
  • The dispatch collapses. Actual. One requires { a.validate(v, ctx); } in dispatch_value. Nothing else.
  • The entry points don’t change. Actual, but the work behind the entry points changed: validate_impl became walk_members + dispatch_value, and should_stop() checks had to be placed at new boundaries (optional unwrap, per-element iteration) that didn’t exist at Stage 8. Not difficult, but not zero. Call it eighty percent mechanical. The remaining twenty was figuring out where FailFast had to be re-gated after recursion shape changed.

Next

The Stage 18 post left a list of five candidates. Migration was number five. The others still stand, and one is now naturally next:

  • Constexpr validation. Every validate() member is already marked constexpr. ValidationContext isn’t, because it holds std::vector<ValidationError>. Replacing it with a constexpr-friendly collector (a fixed-capacity array, or a compile-time-appendable buffer through std::define_static_array tricks) would let a struct User u{…}; literal be checked against its annotations at compile time, producing a static_assertequivalent failure if any annotation rejects. That’s a worthwhile next post — compile-time validation of literal structs is the thing reflection is supposed to make easy. The arc that ran through Stages 16–18 — “open the annotation set” — had a single-file demonstration and a header-only shipping version. From here, the question isn’t whether the set is open. It’s what compile-time work the open set lets you do.
EOF — 2026-04-20
> comments