Two Months, Twenty Stages
Ten posts back, the first one started with a complaint about a WSL2 build and a four-line program that printed x\ny. The last one ended with the same header carrying two projections of the same struct — a runtime validator and a JSON Schema document, built from one declaration.
Between those two posts is a two-month arc I want to look back at before the repo goes quiet. Not a recap of the stages — the README has those listed in order, and each post covers its slice in detail. More like: what I actually thought I was doing, where the shape surprised me, and why this particular project had an ending at all.
What I thought I was doing
The question I started with was small. Can you write a declarative validator in C++26 without macros? Every C++ project I’d seen that wanted “annotate this field, validate it automatically” reached for BOOST_DESCRIBE, a DEFINE_REFLECT(MyStruct, age, name, …) boilerplate, or some code-generation pass. P2996 (reflection) and P3394 (annotations) landed together in Bloomberg’s clang fork. If the two papers lived up to what they claimed, the macros shouldn’t be necessary at all.
The project’s CLAUDE.md had one absolute rule from day one: no #define tricks. Not “minimize macros” — zero. Every annotation a C++ attribute, every walk a reflection query, every check a real function. If the shape didn’t work out macro-free, the honest conclusion was “not yet, come back when the papers merge.”
That rule forced most of what the arc became. The interesting questions — how annotations carry behavior, how a walker dispatches without knowing the annotation set, how a schema projects alongside a validator — only stay interesting if the machinery underneath is the language itself. The moment you start inventing syntactic sugar through macros, the shape of the underlying model stops mattering.
The first library happened fast
Stages 1 through 8 took maybe a week. Reading annotations, turning them into validators, threading a ValidationContext through the walk, nesting into struct members, bolting on a CollectAll / FailFast policy layer, wrapping the whole thing as a header-only library. Each step was one or two hundred lines of self-contained code, compiled with one command, and worked on the first or second try.
I remember writing Stage 3 — the first validator that actually rejected a bad int — and noticing it didn’t feel like a feat. ^^T, nonstatic_data_members_of, annotations_of, extract, a template for over the members, a requires-guarded dispatch. That’s the whole mechanism. It reads like a tutorial, because the papers had done the design work; I was just connecting the named functions.
If I’d stopped at Stage 8, the project would have been a legitimate answer to the original question. Yes, you can write a declarative validator in C++26 without macros, and it fits in a small header. Done.
I didn’t stop because the shape kept showing me I hadn’t hit the real question yet. “What can you do” isn’t the same as “what is the shape trying to be.”
The regex detour taught me what reflection is for
Stages 9 through 12 sprawled. They were supposed to be one stage — add a Regex annotation — and instead they became four, each about a different cost curve for compile-time caching.
The naive Regex annotation (Stage 10) rebuilt the std::regex object on every validate call. Obvious performance disaster. The function-local static cache (Stage 11) fixed the per-call cost but paid a map lookup + mutex on every call, and the cache was keyed by the pattern string — one logical pattern could end up with multiple cache entries if it was declared at multiple sites. Stage 12 is where reflection did something I couldn’t have done otherwise:
template <std::meta::info Annotation>
const std::regex& cached_regex() {
static const std::regex r{get_pattern_from(Annotation)};
return r;
}The std::meta::info of an annotation, used as an NTTP. Each distinct annotation call site gets its own function template instantiation, which gets its own function-local static. No map, no mutex, one regex per declaration site, all folded through the type system.
That was the first moment the arc shifted from “use reflection to do the usual things more directly” to “reflection lets you express things that weren’t expressible.” A template parameter keyed on a reflection of a thing in the program. Value-NTTP folding did similar work in earlier drafts of C++ (Stage 11’s attempt), but reflection-NTTP is strictly stronger — it carries the identity of the annotation, not just its bit-pattern.
The regex stages are the ones I keep expecting someone to push back on. “You could have solved this with a constexpr std::string_view NTTP.” True, for regex. But the pattern generalizes — anything that wants to cache per-site, keyed on compile-time identity of the thing being annotated, not just its payload, goes through std::meta::info. I haven’t needed it since, but the shape is there.
Stages 9-12 also set the pattern that defined the rest of the arc: the interesting question shows up when you look carefully at what you just built. Stage 10 was fine. Stage 11 looked fine. Stage 12 was only obvious in retrospect, once stages 10 and 11 made the problem shape visible.
The closed set cracked open
Stages 13 through 16 were, on the surface, about data shapes. std::optional<T>, std::vector<T>, container-level MinSize / MaxSize annotations, and finally a Predicate<F> wrapper that took an arbitrary callable. Each one looked like feature work.
What they actually did was surface the library’s hidden assumption: the annotation set was closed. Range, MinLength, MaxLength, NotEmpty, MinSize, MaxSize, NotNullopt — the walker knew every one by name and had a branch for each. Stage 16’s Predicate<F> was the first annotation whose behavior came from outside the library. A user wrote a lambda, passed it into Predicate{…}, and the walker suddenly had to dispatch to code it had never seen.
The structural-lambda rule was the surprise that spent a day for me. P3394 says captureless lambdas are structural types, so they can be template parameters. Capturing lambdas aren’t, because they have non-static data members. The rule is sharp, and I tripped over it by writing:
int threshold = 10;
[[=Predicate{[threshold](int x){ return x> threshold;}}]]
int score;The compiler rejected the annotation with a message that didn’t immediately read as “your lambda captured.” A day later I had it — structural means “no state in the closure.” Remove the capture, pass threshold as a template parameter to a helper, and the lambda becomes structural again. The constraint wasn’t arbitrary; it was what made the closure’s type usable as an NTTP in the first place.
That constraint shaped the rest of the arc, because it made clear: if users were going to write their own annotations, the shape had to accommodate arbitrary code — not just closures with specific properties. Stage 16 opened the annotation set with one new branch in the dispatch ladder. Stage 18 removed the ladder entirely.
The flip
Stage 17 looked like a refactor. Split the walker into walk_members<T> (for structs) and dispatch_value<Member, V> (for the value through all its wrapper layers). Make the annotation ladder run at every layer of the recursion. The immediate payoff was that Range on a std::optional<int> field started working — previously the ladder only saw the optional, not the int inside. After Stage 17, the ladder traveled through the wrappers.
What I didn’t see coming was that making the ladder travel meant the ladder didn’t need to know the annotations anymore. If it ran at every layer and asked each annotation “can you handle this value?” via if constexpr, the branch structure collapsed. Stage 18’s core is three lines:
template for (constexpr auto ann : std::define_static_array(std::meta::annotations_of(Member))) {
using A = [:std::meta::type_of(ann):];
constexpr auto a = std::meta::extract<A>(ann);
if constexpr (requires { a.validate(v, ctx); }) {
a.validate(v, ctx);
}
}Every annotation carries its own validate(). The walker asks “can you validate?” and calls through. The annotation decides its own apply-site via its own if constexpr — Range decides it only runs on numerics, MinLength only on things with .size(), NotNullopt only on optionals. The library stops being a closed set with extension points and starts being a protocol with participants.
That flip was two days of writing, and six hours of it was reading the old dispatch ladder until I was sure I understood what it was doing. The refactor is always the easy part; the realization that the refactor is a category change rather than a cleanup is the thing that takes the time.
Stage 18 is where I stopped thinking of this project as “a validator” and started thinking of it as “an annotation protocol with a validator as its first consumer.”
The compiler becomes a runtime
Stage 19 was the one I’d been avoiding. Compile-time validation — static_assert(passes(obj), first_error(obj)) on a literal struct — was obviously the thing the protocol wanted to do, but I expected the std::vector<ValidationError> in the context to block it. You can’t have heap-allocated state surviving past a consteval evaluation.
Four probes showed me the actual shape of the problem, and it wasn’t the one I’d expected.
std::formatis not yetconstexprin this libc++. P2286 specified it; the implementation hasn’t caught up. That closed the obvious path — annotation messages built withstd::formatcouldn’t cross into consteval.- The walker itself ran at consteval without incident.
template for, reflection splices,std::define_static_array,std::meta::extract, and even transientstd::vector<ValidationError>— none blocked. C++20’s transient-allocation rule, plus clang-p2996’s honest implementation of it, handled the vector as long as it was gone by the time the evaluation returned. std::to_charsisconstexprfor integral types in C++23 (P2291). A four-line mini-formatter on top of it produced the same message bytesstd::formatproduced at runtime. Byte-identical.static_assert’s second argument accepts a computedstd::string(P2741). The compiler embeds the string, whatever it is, in the diagnostic. Sostatic_assert(passes(u), first_error(u))on a bad literal produceserror: static assertion failed: age: must be in [0, 150], got 200 (Range)— the exact message the runtime walker would write to stderr, delivered as a compile error. Four probes, four small experiments before I touched the header. The cost of being wrong about whetherstd::formatworked at consteval would have been refactoring every annotation twice; the probe made it a day of isolated experiment instead. That’s the pattern I kept coming back to — write the smallest thing that proves or disproves the assumption before making the assumption load-bearing.
What Stage 19 actually delivered was a phase change. Up to that point, the library had a compile-time half (reflection) and a runtime half (validation). Stage 19 made the validation half run at compile time too. The compiler became the runtime. If your config struct violated its own annotations, the build failed, and the failure pointed at the field and the constraint.
That’s not a new trick in every ecosystem — Rust’s macro-driven #[derive(Validate)] crates produce similar diagnostics. What’s specific to C++26 is that there’s no derivation step. The annotation is on the field; the walker runs; the compiler is already inside the consteval evaluation. Every piece is already in the language. The pattern reads out of standard machinery.
The contract
Stage 20 was the easiest stage to see coming. If annotations carry behavior through validate(v, ctx), and the walker never looks at annotation identity, then any other per-annotation-per-member behavior should fit the same shape. The question wasn’t whether it would work — Stage 18’s protocol shape guaranteed the mechanism. The question was what projection was worth doing.
JSON Schema. One declaration, two consumers: runtime validator and schema document. Range{0, 150} on int age → “reject ages outside [0, 150]” at runtime, “the contract is age in [0, 150]” in the schema. If the schema drifts from the validator, a static_assert(json_schema<T>() == R"(…)") pins the output byte-for-byte, and a runtime walker pins the validator to the same annotation. Drift is impossible, because both consumers read the same source.
Three probes, again. Nested string composition at consteval. Type dispatch including double + Range → "type":"number". NotNullopt as a cross-cutting signal — not a field-local schema fragment, but a flag that lifts into the parent object’s "required" array. The mechanics worked; the header change was mechanical.
What made Stage 20 the closing piece wasn’t the feature. It was that the feature was predictable from Stage 18. The protocol shape made the second projection a transcription. That’s the moment where a pattern has proven itself — when the next use of it isn’t interesting anymore, because you already know the shape will hold. Past that point, more stages would be confirming the same shape with different payloads. The arc was done.
What I didn’t expect
Three things, in rough order of surprise.
The closed-set-to-open-set flip cost almost nothing. I spent stages 1 through 17 building a library with a fixed annotation set and an explicit dispatch ladder. Stage 18’s refactor removed the ladder and replaced it with a one-line requires probe. Every existing annotation kept working with one new member added. The diff was small. The category change was total. That pattern — a conceptual turn that’s also a small diff — shows up in good-shape code, and it’s never what I expect from a “major refactor” plan.
Transient consteval allocations carry more weight than they look like they should. I kept assuming I’d hit a limit. std::vector<ValidationError> in a consteval context? Surely that’s the ceiling. It wasn’t. As long as the evaluation terminated with the vector gone — either by returning something simpler (a bool, a std::string) or by binding only to a static_assert that consumed the result at translation time — the consteval engine handled it. The one wall I did hit was “you can’t bind a consteval std::string to a runtime const std::string variable” — and even that wall was the language being consistent, not arbitrary.
The useful abstraction wasn’t “validator.” It was “annotation-reflective walk.” I built this as a validator and the arc kept pulling me toward the general shape underneath. Reflection + annotations + a wrapper-piercing walker is a protocol for per-type per-field decisions. Validation is the first consumer. Schema emission is the second. A form generator, a database migration emitter, a serialization visitor, a CLI parser — all fit the same shape. None of those are in this repo, and I don’t plan to write them. But the shape is there, and that’s the thing reflection unlocks.
Freezing the build log
There are stages 21 through N that would fit in the arc. Fold Regex<N> into the header. Add i18n through a pluggable formatter. Push std::format out of the policy layer so collect / check / validate work at consteval too. Probably stage a fuzzer. Maybe write a CLI that consumes the generated JSON Schema and validates JSON input with the schema, matching the C++ walker’s output.
None of them are this arc. They’re polish, integration, and extension, not new shape. The shape the ten posts were about — reflection as a mechanism, annotations as behavior carriers, a walker that doesn’t know its annotation set, a compile-time phase that renders validator and contract from the same declaration — reaches its closing move at Stage 20. Anything past that is a different project.
So the repo freezes. Twenty stages, ten posts, one header-only library at include/validator.hpp, pinned to a single commit. Every stage and every test runs under clang-p2996; tools/rebuild_all.sh checks they still do. The README points to the stages in order and the posts in order. If you want to see how annotation-reflective design feels in C++26 before P2996 lands in mainline, the log is there.
If P2996 does land in mainline — clang trunk, gcc, eventually MSVC — then a real library is worth writing, and this repo is a reference for shape, not a dependency. That library would have proper error types, i18n, richer predicates, schema draft URIs, maybe a serialization parallel to the schema. It’ll be a fresh project on a fresh timeline. Someone willing to commit to it — maybe me, probably not for a while.
What this two-month walk was about isn’t that library. It’s that the shape exists, in the language today, without macros, in a small enough piece of code that one person can hold all of it in their head. Stage 1 asked whether the shape was real. Stage 20 answered yes, in full. Everything between is evidence.
The build log is closed. The arc is what it was trying to be.