A Declarative Validator in C++26 — No Macros, No Codegen
The pitch is one line:
struct User {
[[=Range{0,150}]] int age;
[[=Range{1,1'000'000}]] int id;
};Attach constraints to fields like attributes, ask the compiler to walk them, get back a list of what’s wrong. No macros, no external schema, no code generator. The type is the schema.
C++26 is the first version of the language where that’s a reasonable thing to build. This post walks the whole thing end-to-end, from one-field reflection smoke test to a real validator with structured errors and path tracking. Each stage is a single file, a single commit hash, and a compile you can reproduce.
Code: github.com/Ricci-curvature/reflecting-cpp26 — every section links to the exact commit.
Why bother
There are already four ways to validate a struct in C++, and all of them are annoying:
-
Hand-written
if/else. The constraint is in the function, the type is in the header, and nothing links them. Add a field, forget a check, ship the bug. -
Macros (
BOOST_DESCRIBE, X-macros, customREGISTER(...)). The constraint lives next to the type, but you’re now writing metaprogramming in a string-paste language with no types and no tooling. Errors point at column 1 of line 82,000. -
JSON Schema / external DSL. Two sources of truth. Drift is not an if, it’s a when.
-
Boost.PFR. Positional tuple access, no names, no custom metadata. You can see that field 0 is an
int; you can’t see that it’sageand must be in[0, 150]. C++26 adds two pieces that, together, kill all four: -
P2996 (reflection):
^^Tis a compile-time handle to a type. You can enumerate its members, read their names and types, splice them back into code. -
P3394 (annotations):
[[=Value{...}]]attaches a typed value to a declaration. Reflection can read it back. Put them together and the validator stays short, macro-free, and readable end-to-end in a single file.
Setup
Two paths. Pick one.
Compiler Explorer (fastest)
Compiler Explorer carries a clang-p2996 build under x86-64 clang (experimental P2996). Flags:
-std=c++26 -freflection-latest -stdlib=libc++
That’s the whole setup. No install, no build. Good enough to paste Stages 1–2 and see them run. For Stage 3 onwards the examples start importing <format> and <vector>, which work on CE but may need the explicit -stdlib=libc++ every time.
CE’s available compilers shift; if the P2996 one is missing, check the compiler list for anything labeled P2996, reflection, or experimental C++26.
Local build
If you want template for to stop being a party trick and start being part of your actual workflow, you’re going to want a local compiler. I wrote that up separately: Building clang-p2996 for C++26 Reflection. Two things worth highlighting from that post before you try any of this:
- You need both
LLVM_ENABLE_PROJECTS=clangandLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind". GCC’s libstdc++ has no<meta>. std::define_static_array(notstd::meta::define_static_array) is the bridge that makestemplate forwork over a reflected member list. If you skip it, the diagnostics don’t help. Every compile below uses the same invocation:
/home/user/src/clang-p2996/build/bin/clang++ \
-std=c++26 -freflection-latest -stdlib=libc++ -nostdinc++ \
-isystem .../c++/v1 -isystem .../x86_64-unknown-linux-gnu/c++/v1 \
-L .../lib/x86_64-unknown-linux-gnu -Wl,-rpath,... \
-o out src.cppThe project has a tools/cxx wrapper so each stage is a one-liner: ./tools/cxx stages/03_first_validator.cpp.
The plan
Eight stages. Each one is a single .cpp file that compiles and runs on its own. No refactoring retroactively — each stage is frozen as a commit so you can read it in isolation.
| # | What | Status |
|---|---|---|
| 1 | Hello reflection — iterate one struct’s fields | done |
| 2 | Read annotation values off members | done |
| 3 | First validator, collection-based core, bool public API | done |
| 4 | ValidationContext — structured errors + path tracking | done |
| 5 | MinLength / MaxLength / NotEmpty — strings and tag-type annotations | done |
| 6 | Nested structs — recursion and dotted paths (user.address.zip) | done |
| 7 | Policy layer — validate (throw) / check (expected) / collect (vector) | done |
| 8 | Header-only release — include/validator.hpp | done |
Heads-up: these pinned snapshots predate the comment translation — stages 1–7 still show the original Korean. Current state in 755c15d is English.
Stage 1 — Hello reflection
Commit: 8f1011f
Before any validation logic, prove the pipeline works: take a type, walk its non-static data members, print each name.
#include<meta>
#include<iostream>
struct User {
std::string name;
int age;
std::string email;
};
int main() {
template for (constexpr auto member :
std::define_static_array(
std::meta::nonstatic_data_members_of(
^^User, std::meta::access_context::unchecked())))
{
std::cout << std::meta::identifier_of(member) << '\n';
}
}Output:
name
age
email
Three things happening here that never existed in pre-C++26:
^^Useris the reflect operator. It produces astd::meta::info— a compile-time handle to the type.nonstatic_data_members_of(^^User, ...)returns astd::vector<std::meta::info>, one entry per member. Yes, you get astd::vectoratconstevaltime. This is fine in C++26.template foris an expansion statement (P1306): the loop body is instantiated once per element, at compile time. It’s not a runtimefor.
Two errors you’ll hit if you freelance this
Drop define_static_array:
template for (constexpr auto m :
std::meta::nonstatic_data_members_of(^^User, ...))error: constexpr variable '__range' must be initialized by a constant expression
note: pointer to subobject of heap-allocated object is not a constant expression
note: heap allocation performed here
nonstatic_data_members_of hands you transient heap-allocated storage that existed only during constant evaluation. template for needs a static range — something whose address doesn’t vanish the moment constant evaluation ends. std::define_static_array (note: std::, not std::meta::) copies the contents into static storage so the expansion has something stable to iterate. Without it, you can’t even compile the loop.
Drop the access_context:
std::meta::nonstatic_data_members_of(^^User)error: no matching function for call to 'nonstatic_data_members_of'
note: candidate function not viable: requires 2 arguments, but 1 was provided
Older P2996 drafts had a one-argument form. The current Bloomberg fork requires a second argument — a std::meta::access_context, which is how the metafunction decides whether to honour private/protected. For a learning project, access_context::unchecked() is the “give me everything” escape hatch. access_context::current() respects the calling scope’s access rights.
Both of these would be immediately obvious from the compiler’s own “did you mean” suggestion if you got them wrong. I still wasted half an hour on the first one because the error points at __range, not at the reflection call that produced __range.
Stage 2 — Reading annotation values
Commit: 2eed156
Annotations aren’t attributes in the usual “parser ignores them” sense. They’re values with types, attached to a declaration, readable via reflection.
struct Range {
long long min, max;
constexpr Range(long long lo, long long hi) : min(lo), max(hi) {}
};
struct NotEmpty {};
struct Sample {
[[=Range{1,100}]] int count;
[[=NotEmpty{}]] std::string name;
int no_annotation;
};Two deliberately different shapes:
Range— a literal class with data. Carries{1, 100}.NotEmpty— an empty tag type. Its mere presence is the information.
The error you’ll hit first: [:ann:] in iteration
The intuitive read of the P2996 paper is: “splice everywhere”. The reflect operator gives you a std::meta::info; the splice [:...:] gives you back the entity. So this should work:
template for (constexpr auto ann :
std::define_static_array(
std::meta::annotations_of(member)))
{
constexpr auto r = [:ann:]; // <-- tried first
std::cout << r.min << '\n';
}Compile:
error: reflection is not usable in a splice expression
The error wording is telling. [:ann:] wants to splice a reflection back into source code. That works fine for a type ([:^^int:] → int) or a non-type entity ([:^^some_function:] → the function). It does not work when the reflection you hold is the value of an annotation — specifically when ann is the loop variable of a template for and its value is “this particular annotation’s stored object.” The compiler has the value, but splicing it back into a constant-expression context isn’t the operation that makes sense.
The right tool is std::meta::extract<T>:
if constexpr (std::meta::type_of(ann) == ^^Range) {
constexpr auto r = std::meta::extract<Range>(ann);
std::cout << " Range { min=" << r.min << ", max=" << r.max << " }\n";
} else if constexpr (std::meta::type_of(ann) == ^^NotEmpty) {
std::cout << " NotEmpty\n";
}extract<T>(info) reconstructs a T from a reflection of a T-valued annotation. It’s the way to read an annotation value. The splice is for entities; extract is for values.
One more non-obvious detail in that snippet: comparing types is ****std::meta::type_of(ann) == ^^Range, not a concept or std::is_same_v. You’re comparing two std::meta::info values at compile time — the type reflection of the annotation, and the type reflection of Range. operator== on std::meta::info is a defined constant-expression operation.
Output
field: count
annotation: Range { min=1, max=100 }
field: name
annotation: NotEmpty
field: no_annotation
(no annotations)
Three fields, three branches exercised: Range with data, tag type, no annotations at all. The CLAUDE.md for this project calls out both of these rules (“type comparison is ==”, “read values with extract”) because I hit them both.
Stage 3 — The first validator
Commit: 070c662
Now the interesting decision. The public API is bool validate(const T&) — did it pass? — but internally, the validator never early-returns. It walks every member, checks every annotation, counts all failures, then returns.
namespace detail {
template <typename T>
constexpr std::size_t validate_impl(const T& obj) {
std::size_t failures = 0;
template for (constexpr auto member :
std::define_static_array(
std::meta::nonstatic_data_members_of(
^^T, std::meta::access_context::unchecked())))
{
template for (constexpr auto ann :
std::define_static_array(
std::meta::annotations_of(member)))
{
if constexpr (std::meta::type_of(ann) == ^^Range) {
constexpr auto r = std::meta::extract<Range>(ann);
auto v = obj.[:member:];
if (v < r.min || v > r.max) {
++failures;
// no early return — collection philosophy
}
}
}
}
return failures;
}
} // namespace detail
template <typename T>
constexpr bool validate(const T& obj) {
return detail::validate_impl(obj) == 0;
}Output for three inputs (good, one_fail, two_fail):
good: validate=true, failures=0
one_fail: validate=false, failures=1
two_fail: validate=false, failures=2
The two_fail case is the proof. Both age=200 and id=-1 are out of range, and both get counted. If the core bailed on the first error, you’d never see the second one.
Why this matters: the C++ validator world is full of fail-fast libraries that only ever report one problem per validation pass. Good for performance, terrible for UX. Anyone who’s filled out a form and corrected five fields one at a time knows why.
This architecture — core always collects, policy wraps — is the spine of the rest of the project. Stage 4 turns the ++failures into errors.push_back(...) without changing the control flow. Stage 7 adds a ctx.mode for opt-in fail-fast, but the core still asks the context whether to stop, not the caller.
The one thing worth staring at: obj.[:member:]. That’s the splice. member is a std::meta::info reflecting a non-static data member; [:member:] splices it back into a member access expression. The result has the member’s actual type and is a genuine lvalue into obj — not a copy, not a tuple-like accessor. It’s a language feature, not a library trick.
Stage 4 — Structured errors and paths
Commit: 36d60f2
Counting failures is fine for a smoke test, useless for a real user. Stage 4 swaps the counter for a ValidationContext:
struct ValidationError {
std::string path;
std::string message;
std::string annotation;
};
struct ValidationContext {
std::vector<ValidationError> errors;
std::vector<std::string> path_stack;
std::string current_path() const {
std::string result;
for (std::size_t i = 0; i < path_stack.size(); ++i) {
if (i > 0) result += '.';
result += path_stack[i];
}
return result;
}
};The validate_impl change is mechanical — ++failures becomes ctx.errors.push_back(...):
template for (constexpr auto member : /* ... */) {
ctx.path_stack.push_back(
std::string{std::meta::identifier_of(member)});
template for (constexpr auto ann : /* ... */) {
if constexpr (std::meta::type_of(ann) == ^^Range) {
constexpr auto r = std::meta::extract<Range>(ann);
auto v = obj.[:member:];
if (v < r.min || v > r.max) {
ctx.errors.push_back({
ctx.current_path(),
std::format("must be in [{},{}], got{}", r.min, r.max, v),
"Range"
});
}
}
}
ctx.path_stack.pop_back();
}Public API gains a collect alongside validate:
template <typename T>
std::vector<ValidationError> collect(const T& obj) {
ValidationContext ctx;
detail::validate_impl(obj, ctx);
return std::move(ctx.errors);
}
template <typename T>
bool validate(const T& obj) { return collect(obj).empty(); }Error formatting is a two-line helper on the driver side, spec-fixed as <path>: <message> (<annotation>):
std::string format_error(const ValidationError& e) {
return std::format("{}:{} ({})", e.path, e.message, e.annotation);
}Output:
good: validate=true, errors=0
one_fail: validate=false, errors=1
age: must be in [0, 150], got 200 (Range)
two_fail: validate=false, errors=2
age: must be in [0, 150], got 200 (Range)
id: must be in [1, 1000000], got -1 (Range)
The path_stack is over-engineered for Stage 4 — the struct is flat, so every path is one segment. It pays off in Stage 6 when User gains an Address and the path becomes user.address.zip_code. Pushing/popping per member rather than passing a path string down lets the recursion stay clean.
A design note that won’t surface for a few more stages: ValidationContext is the only place fail-fast will live. Not the policy wrappers, not the core. When Stage 7 adds a mode, it’ll be ctx.should_stop() checked inside validate_impl — the core still walks, the context decides. Which means none of the code above changes when fail-fast arrives.
Stage 5 — MinLength, MaxLength, NotEmpty
Commit: 32d79f3
Three new annotation types, one for each shape a real validator ends up needing:
struct MinLength { std::size_t value; constexpr MinLength(std::size_t v) : value(v) {} };
struct MaxLength { std::size_t value; constexpr MaxLength(std::size_t v) : value(v) {} };
struct NotEmpty {};
struct User {
[[=Range{0,150}]] int age;
[[=MinLength{3}]] [[=MaxLength{32}]] std::string name;
[[=NotEmpty{}]] std::string email;
int unrelated;
};MinLength and MaxLength carry data. NotEmpty is a tag type — its presence is the information, there’s nothing to read out. Stage 2 demonstrated these two shapes going through the same iteration pipeline; Stage 5 is the first time they both flow into a real validator.
The natural next step is to extend the if constexpr ladder from Stage 4 with three new branches. Which is exactly what I did, and which is exactly how I ran into the first real compiler quirk of the project.
The quirk: if constexpr + template for + reflection conditions
The first attempt:
} else if constexpr (std::meta::type_of(ann) == ^^MinLength) {
constexpr auto r = std::meta::extract<MinLength>(ann);
const auto& v = obj.[:member:];
if (v.size() < r.value) {
ctx.errors.push_back({
ctx.current_path(),
std::format("length must be >={}, got{}", r.value, v.size()),
"MinLength"
});
}
}Compile output:
error: member reference base type 'const int' is not a structure or union
87 | if (v.size() < r.value) {
| ~^~~~~
note: in instantiation of expansion statement requested here
Translation: for the age member (which is int, annotated with Range), the compiler tried to typecheck the MinLength branch body. It resolved v to const int, then complained that int has no .size().
This shouldn’t happen. Textbook if constexpr discards the false branch in a template context, and the branch condition (type_of(ann) == ^^MinLength) evaluates to false when ann is Range.
Why Stage 4 didn’t hit this: only one branch existed (Range), and its body used v < r.min — an expression that happens to be valid for any numeric v. There was no body that demanded a member-specific operation. Stage 5’s .size() demands it, and the compiler fails the body even though it “shouldn’t” look inside.
My reading is that template for’s expansion mechanics don’t fully respect if constexpr branch discarding when the condition is a reflection comparison. It’s a narrow, specific quirk — worth a compiler bug report once I’ve minimized it, but not a blocker.
The workaround: requires guard inside each branch
Add a second if constexpr layer using requires:
} else if constexpr (std::meta::type_of(ann) == ^^MinLength) {
if constexpr (requires { obj.[:member:].size(); }) {
constexpr auto r = std::meta::extract<MinLength>(ann);
const auto& v = obj.[:member:];
if (v.size() < r.value) {
ctx.errors.push_back({
ctx.current_path(),
std::format("length must be >={}, got{}", r.value, v.size()),
"MinLength"
});
}
}
}requires { obj.[:member:].size(); } is a standard SFINAE-style check: “is .size() callable on that splice?” For int, it’s false, so the inner body is discarded by the inner if constexpr, which discards reliably because that’s ordinary C++20.
Apply the same guard to MaxLength (.size()) and NotEmpty (.empty()). Range’s body (v < r.min) still works without a guard because the expression is valid for every numeric type in the current struct — but I’d add one there too in a real library, for defensiveness.
Output
good: validate=true, errors=0
short_name: validate=false, errors=1
name: length must be >= 3, got 2 (MinLength)
long_name: validate=false, errors=1
name: length must be <= 32, got 33 (MaxLength)
empty_email: validate=false, errors=1
email: must not be empty (NotEmpty)
multi_fail: validate=false, errors=3
age: must be in [0, 150], got 200 (Range)
name: length must be >= 3, got 2 (MinLength)
email: must not be empty (NotEmpty)
multi_fail is the important line. The core still collects everything — Range on age, MinLength on name, NotEmpty on email, all three — in a single pass. No early exit.
What Stage 5 is really about
By Stage 5 the validator covers enough ground to feel real: annotations that carry data (Range, MinLength, MaxLength), annotations that are pure tags (NotEmpty), and both scalar and string member types — all running through a single dispatch function with one branch per annotation type. The if constexpr chain is a weak link — every new annotation adds a branch — but it’s obviously transformable into something nicer once there’s a reason. For now, this is the floor.
Two CLAUDE.md-worthy lessons:
requires** is the fallback when reflection-basedif constexprdiscards don’t fire.** Not a replacement — a belt-and-braces second layer.- Keep annotation bodies in helper forms the compiler can reliably discard. Either a
requiresguard or (a pattern for later) a helper function template wherev’s type drives dispatch.Regexis still open. The interesting part is thatstd::regexisn’t a literal class, so it can’t be an annotation. The standard trick is[[=Regex{"^[a-z]+$"}]]whereRegexstores astring_viewand the validator constructs a realstd::regexat call time. I’ll fold that in when it stops feeling like a distraction from Stage 6.
Stage 6 — Nested structs
Commit: afe66a0
Stage 4 built a path_stack that only ever held one element at a time. Stage 6 is where it finally earns its keep.
struct Address {
[[=MinLength{2}]] std::string street;
[[=Range{1,99999}]] int zip_code;
};
struct User {
[[=Range{0,150}]] int age;
[[=MinLength{3}]] [[=MaxLength{32}]] std::string name;
[[=NotEmpty{}]] std::string email;
Address address; // no direct annotation — recurse into this
int unrelated;
};address has no annotation on the member itself. Its constraints live on its own members (street, zip_code). The validator has to notice the difference: for age, apply Range; for address, recurse.
The dispatch question
For each member, after running annotations, should we recurse into it? The naïve read is: “yes, if it’s a class type.”
using MT = std::remove_cvref_t<decltype(obj.[:member:])>;
if constexpr (std::is_class_v<MT>) {
validate_impl(obj.[:member:], ctx);
}This works for Address. It also recurses into std::string, which is a class type. std::string’s non-static data members are implementation-specific names like _M_dataplus, _M_string_length, etc. None of them carry validator annotations, so nothing breaks — but the compiler instantiates validate_impl<std::string> (and validate_impl<__gnu_cxx::__alloc_traits<...>>, and so on down the libc++ rabbit hole). It’s wasted compile time, and the path_stack briefly picks up internal member names between push and pop.
Worse, it sets a precedent: “any class-typed member I didn’t explicitly annotate will get recursed.” That’s not the contract I want.
The fix: is_aggregate_v
std::is_aggregate_v<T> is true only for C’s idea of a struct: no user-declared constructors, no private non-static members, no virtual functions, no virtual bases.
Address { std::string street; int zip_code; }— no constructors declared. Aggregate. Recurse.std::string— has constructors. Not aggregate. Leaf.int— not a class. Not aggregate. Leaf.Range { constexpr Range(long long, long long); }— has a user-declared constructor. Not aggregate. Never recursed into (though we don’t hit this sinceRangeis an annotation type, not a member type). This gives a clean split: “user-defined plain struct = recurse; anything else = leaf.” It’s a hard line, imposed by C++ grammar, not a library convention I have to document and enforce.
using MT = std::remove_cvref_t<decltype(obj.[:member:])>;
if constexpr (std::is_aggregate_v<MT>) {
validate_impl(obj.[:member:], ctx);
}One deliberate trade-off: if you want a nested validatable struct, it has to be an aggregate. Add a custom constructor and Stage 6’s dispatch skips it. That’s a real constraint, and it’s written into the code’s shape rather than hidden in a naming convention.
Path assembly
The path_stack from Stage 4 now pulls its weight. For each member the loop pushes its name, does the work (annotations + possibly a recursive call), and pops. When the recursive call pushes its own member’s name, current_path() joins the whole stack with .:
- Outer call: push
address→ recurse- Inner call: push
street→ MinLength fails →current_path()="address.street"→ push error → pop - Inner call: push
zip_code→ Range fails →current_path()="address.zip_code"→ push error → pop
- Inner call: push
- Outer call: pop
addressThe code implementing this didn’t need to change from Stage 4 —current_path()already joined with.. Stage 6 just finally exercises the second segment.
Output
good: validate=true, errors=0
bad_zip: validate=false, errors=1
address.zip_code: must be in [1, 99999], got 0 (Range)
bad_street: validate=false, errors=1
address.street: length must be >= 2, got 1 (MinLength)
multi_fail: validate=false, errors=5
age: must be in [0, 150], got 200 (Range)
name: length must be >= 3, got 2 (MinLength)
email: must not be empty (NotEmpty)
address.street: length must be >= 2, got 1 (MinLength)
address.zip_code: must be in [1, 99999], got 0 (Range)
multi_fail is the proof that collection survives the recursion boundary. Three failures at the top level (age/name/email) plus two at the nested level (address.street/address.zip_code), all five in the same std::vector<ValidationError>. Neither the outer nor the inner validate_impl calls exit early — each one walks every member, and the recursive call is just another step in that walk.
No compile errors this time. Stage 5’s requires guards on every branch meant Stage 6 added the recursion without any new surprises.
Stage 7 — Policy layer
Commit: e98d973
Up through Stage 6, there was exactly one way to consume the validator: get back a std::vector<ValidationError>, inspect it, decide what to do. That’s the only shape the core speaks, and it should stay that way. Stage 7 adds the three thin wrappers that match what callers actually want.
template <typename T>
std::vector<ValidationError> collect(const T& obj, Mode mode = Mode::CollectAll);
template <typename T>
std::expected<void, std::vector<ValidationError>>
check(const T& obj, Mode mode = Mode::CollectAll);
template <typename T>
void validate(const T& obj, Mode mode = Mode::CollectAll); // throws on failurecollect— the existing shape. Unchanged behaviour, now takes an optionalMode.check—std::expected<void, Errors>. Success is void; failure is the full error list. Maps onto C++23’s standard “maybe-success-with-error-reason” shape so callers can.and_then(...)or pattern-match.validate— throwsValidationExceptioncarrying the error list. For callers who treat validation failure as a genuine exceptional state. All three are implemented on top ofcollect:
template <typename T>
std::expected<void, std::vector<ValidationError>>
check(const T& obj, Mode mode) {
auto errs = collect(obj, mode);
if (errs.empty()) return {};
return std::unexpected{std::move(errs)};
}
template <typename T>
void validate(const T& obj, Mode mode) {
auto errs = collect(obj, mode);
if (!errs.empty()) throw ValidationException{std::move(errs)};
}That’s the whole policy layer. Everything interesting still happens inside validate_impl.
Fail-fast lives in the context, not the policy
The obvious way to add early-exit would be: validate throws on first error, check runs to completion, collect always collects. Three policies, three control flows, three versions of the core.
I went the other way deliberately. All three wrappers collect by default. Fail-fast is a separate axis — a mode on the context:
enum class Mode { CollectAll, FailFast };
struct ValidationContext {
std::vector<ValidationError> errors;
std::vector<std::string> path_stack;
Mode mode = Mode::CollectAll;
bool should_stop() const {
return mode == Mode::FailFast && !errors.empty();
}
// ...
};Each wrapper forwards the mode. collect(obj, Mode::FailFast) asks the context to stop at the first error. validate(obj, Mode::FailFast) does the same, then throws if any error was collected. The wrappers never decide control flow; they only decide the output shape.
Why does this matter? Because the alternative — “validate is fail-fast by definition” — would force the core to know which policy is in use. The core would then encode every policy’s flow in its own body, and every new policy (imagine first_error_per_field, or bounded_collect(max=10)) would require changing the core again. With context-mode, the core asks one question — should_stop() — and the policies only shape the return type.
The core change
validate_impl from Stage 6 gained exactly one thing: a runtime guard around each iteration.
template for (constexpr auto member : /* ... */) {
if (!ctx.should_stop()) {
ctx.path_stack.push_back(/* ... */);
template for (constexpr auto ann : /* ... */) {
if (!ctx.should_stop()) {
// annotation dispatch — unchanged
}
}
if constexpr (std::is_aggregate_v<MT>) {
if (!ctx.should_stop()) {
validate_impl(obj.[:member:], ctx); // recursion respects mode too
}
}
ctx.path_stack.pop_back();
}
}template for doesn’t support break/continue — the iteration count is fixed at compile time. So the guard is a plain runtime if that wraps the body. The expansion still produces N copies, but once should_stop() becomes true, each remaining copy is a no-op. The push/pop stays balanced because both are inside the same guarded block.
The guard at the recursion point is what keeps fail-fast working across the Stage 6 boundary. Once the first error appears, inner structs stop being descended into.
ValidationException
Nothing exotic:
class ValidationException : public std::exception {
public:
std::vector<ValidationError> errors;
explicit ValidationException(std::vector<ValidationError> e)
: errors(std::move(e)),
message_(std::format("validation failed with{} error(s)", errors.size())) {}
const char* what() const noexcept override { return message_.c_str(); }
private:
std::string message_;
};One class, public errors vector, cached what() string. No hierarchy — a single exception type is enough. Anyone who wants per-annotation exception types can filter on annotation inside errors.
Output
=== collect(multi_fail) ===
errors=5
age: must be in [0, 150], got 200 (Range)
name: length must be >= 3, got 2 (MinLength)
email: must not be empty (NotEmpty)
address.street: length must be >= 2, got 1 (MinLength)
address.zip_code: must be in [1, 99999], got 0 (Range)
=== check(good) ===
has_value=true
=== check(multi_fail) ===
has_value=false, errors=5
...
=== validate(good) ===
passed (no throw)
=== validate(multi_fail) ===
threw: validation failed with 5 error(s) (errors=5)
...
=== collect(multi_fail, FailFast) ===
errors=1
age: must be in [0, 150], got 200 (Range)
The last block is the proof. Same multi_fail object that produced five errors under CollectAll now produces exactly one under FailFast — the first one the walker finds, and then it stops. No changes to collect’s body; the mode is doing the work.
No compile errors. The core change was small: one enum, one context method, and a handful of if (!ctx.should_stop()) guards around member iteration, annotation dispatch, and recursion.
Which means the core is now finished. By now, validate_impl does everything the validator is ever going to do — reflect, dispatch, recurse, honour fail-fast. Stage 8 is the packaging stage: take what’s spread across the stages/ timeline, move it into a single include/validator.hpp that another translation unit can #include without pulling in <iostream>, stray usings, or the driver’s main.
Stage 8 — Header-only release
Commit: efde80c
Stages 3–7 lived in stages/NN_*.cpp — each one a self-contained file with its own main, its own #include <iostream>, its own copy of Range/MinLength/etc. That’s the right shape for a tutorial timeline; it’s not the shape you hand to another project.
Stage 8 does the one thing left: move the library into a header.
include/validator.hpp ← the library
stages/08_header_only.cpp ← driver, same behaviour as Stage 7
stages/08_aux_tu.cpp ← second TU, ODR smoke test
What moved, and into what namespace
Everything that was at file scope in stages/07_policy_layer.cpp moves under namespace av (short for annotation validator), and one type — ValidationContext — drops down into av::detail:: because no user ever constructs one directly:
namespace av {
// Public: annotation types
struct Range { long long min, max; /* ... */ };
struct MinLength { std::size_t value; /* ... */ };
struct MaxLength { std::size_t value; /* ... */ };
struct NotEmpty {};
// Public: error surface
struct ValidationError { std::string path, message, annotation; };
enum class Mode { CollectAll, FailFast };
class ValidationException : public std::exception { /* ... */ };
// Internal: context + walker — not API-stable
namespace detail {
struct ValidationContext { /* errors, path_stack, mode, should_stop, current_path */ };
template <typename T>
void validate_impl(const T& obj, ValidationContext& ctx);
}
// Public: policy layer
template <typename T> std::vector<ValidationError> collect (const T&, Mode = Mode::CollectAll);
template <typename T> std::expected<void, std::vector<ValidationError>> check (const T&, Mode = Mode::CollectAll);
template <typename T> void validate(const T&, Mode = Mode::CollectAll);
inline std::string format_error(const ValidationError&);
} // namespace avUser code calls av::validate(user), av::collect(user, av::Mode::FailFast), etc. The detail:: inner namespace is the walker and its scratch state — the only pieces that need reflection primitives, and the only pieces library users shouldn’t be touching. A user who wants to know what went wrong has ValidationError and ValidationException to work with; they never need to look at the context.
ODR: what has to be inline, and why
A header-only library lives or dies by ODR. Every translation unit that includes <validator.hpp> gets its own copy of every definition in the file. Anything the linker later sees as a strong definition in more than one object file is a multiple-definition error.
The rules that make this file safe:
- Templates (
detail::validate_impl,collect,check,validate) — implicitly inline. Each instantiation is vague-linkage; the linker de-duplicates. - Struct/class member functions defined inline —
detail::ValidationContext::should_stop,::current_path,ValidationException::ValidationException,::what. All defined in-class, so implicitly inline. - Non-template free functions —
format_erroris the only one. It gets an explicitinline:
inline std::string format_error(const ValidationError& e) {
return std::format("{}:{} ({})", e.path, e.message, e.annotation);
}Drop the inline and a second TU that includes the header will tell you — loudly — at link time.
- Types (
Range,ValidationError,Mode,ValidationException, …) — struct/class/enum definitions. ODR allows multiple identical definitions across TUs; the namespace gives them a common, non-colliding address in the name tree. That’s it. No static data members, no free variables, nousingat namespace scope. The header doesn’t even#include <iostream>— that’s a driver concern.
Proving it — two TU linker test
The real test of ODR safety is linking. One TU including the header proves it compiles. Two TUs linked together prove it doesn’t duplicate symbols. stages/08_aux_tu.cpp is that second TU:
// stages/08_aux_tu.cpp
#include"../include/validator.hpp"
#include<string>
namespace {
struct Thing { [[=av::MinLength{1}]] std::string label; };
}
std::size_t count_errors_in_aux_tu() {
Thing t{""};
auto errs = av::collect(t);
for (const auto& e : errs) (void)av::format_error(e);
return errs.size();
}Build command — both files, one invocation:
clang++ -std=c++26 -freflection-latest -stdlib=libc++ ... \
-o stages/08_header_only.out \
stages/08_header_only.cpp stages/08_aux_tu.cppLinks clean. Output:
=== collect(multi_fail) ===
errors=5
age: must be in [0, 150], got 200 (Range)
name: length must be >= 3, got 2 (MinLength)
email: must not be empty (NotEmpty)
address.street: length must be >= 2, got 1 (MinLength)
address.zip_code: must be in [1, 99999], got 0 (Range)
...
=== collect(multi_fail, FailFast) ===
errors=1
age: must be in [0, 150], got 200 (Range)
=== aux TU (ODR smoke test) ===
aux_tu_errors=1
First seven blocks match Stage 7 byte-for-byte. The last block is the aux TU reporting back: it called av::collect and av::format_error from a totally separate object file, both TUs included the header, and the linker shrugged. That’s the header-only claim made good.
What the driver looks like now
This is the part that justifies the whole eight-stage walk. Library user code, written against the final API:
#include"../include/validator.hpp"
#include<iostream>
struct Address {
[[=av::MinLength{2}]] std::string street;
[[=av::Range{1,99999}]] int zip_code;
};
struct User {
[[=av::Range{0,150}]] int age;
[[=av::MinLength{3}]] [[=av::MaxLength{32}]] std::string name;
[[=av::NotEmpty{}]] std::string email;
Address address;
};
int main() {
User u{200, "al", "", {"X", 0}};
for (const auto& e : av::collect(u)) {
std::cout << av::format_error(e) << '\n';
}
}No macros. No derived class. No REGISTER(User, age, Range{0,150}). The constraints are attached to the types, the library walks them, and the rest is a normal for loop.
That was the pitch in the first paragraph.
Where this is going
The reflection primitives you’ve seen — ^^T, nonstatic_data_members_of, annotations_of, type_of, extract<T>, [:member:], template for — are the entire surface area of the P2996/P3394 subset this validator needs. Everything else is normal C++: a struct with two vectors, a loop that pushes and pops a path, a std::format call.
That’s the point I keep coming back to. The interesting C++ in a reflection-based validator is not the reflection. It’s the fact that you can stop writing macros and start writing normal code that happens to run at compile time.
What’s left out, deliberately, for a future post:
Regex****.std::regexisn’t a literal class, so it can’t be an annotation value directly. The trick is to store the pattern as astd::string_viewon the annotation and construct thestd::regexat call time. Straightforward — just not in scope for this tutorial’s “minimum complete validator.”- Collections.
std::vector<T>fields,std::optional<T>fields,std::map<K,V>fields. Each needs its own dispatch rule in the walker. The core shape (is_aggregate_vrecursion) extends cleanly; the annotation grammar probably needs another branch. - Custom validators. Register a callable against a member instead of (or alongside) the built-in annotations. Probably
[[=Validate{&my_check}]]whereValidatestores a function pointer. Literal-class rules constrain what’s possible here — worth a post on its own. - Runtime vs compile-time choice. Right now every constraint is compile-time. A real library probably wants a way to switch:
av::validate(obj)for compile-time constraints,av::validate(obj, runtime_schema)for ones loaded from JSON. Each of those is a post of its own. This one ends here — with a header you can#include, a namespace you can use, and a compile command you can reproduce.
This is a living document, closed out with the wrap-up commit.
Library: include/validator.hpp. Walkthrough: stages/01_*.cpp → stages/08_*.cpp.
Current head: fc8870d — README, tests/smoke_test.cpp, ValidationContext moved into av::detail::.