RICCILAB
> blog/cpp26/A-Declarative-Validator-in-C++26

A Declarative Validator in C++26 — No Macros, No Codegen

_DEV_C++26

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, custom REGISTER(...)). 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’s age and must be in [0, 150]. C++26 adds two pieces that, together, kill all four:

  • P2996 (reflection): ^^T is 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=clang and LLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind". GCC’s libstdc++ has no <meta>.
  • std::define_static_array (not std::meta::define_static_array) is the bridge that makes template for work 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.cpp

The 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.

#WhatStatus
1Hello reflection — iterate one struct’s fieldsdone
2Read annotation values off membersdone
3First validator, collection-based core, bool public APIdone
4ValidationContext — structured errors + path trackingdone
5MinLength / MaxLength / NotEmpty — strings and tag-type annotationsdone
6Nested structs — recursion and dotted paths (user.address.zip)done
7Policy layer — validate (throw) / check (expected) / collect (vector)done
8Header-only release — include/validator.hppdone

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:

  • ^^User is the reflect operator. It produces a std::meta::info — a compile-time handle to the type.
  • nonstatic_data_members_of(^^User, ...) returns a std::vector<std::meta::info>, one entry per member. Yes, you get a std::vector at consteval time. This is fine in C++26.
  • template for is an expansion statement (P1306): the loop body is instantiated once per element, at compile time. It’s not a runtime for.

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:

  1. requires** is the fallback when reflection-based if constexpr discards don’t fire.** Not a replacement — a belt-and-braces second layer.
  2. Keep annotation bodies in helper forms the compiler can reliably discard. Either a requires guard or (a pattern for later) a helper function template where v’s type drives dispatch. Regex is still open. The interesting part is that std::regex isn’t a literal class, so it can’t be an annotation. The standard trick is [[=Regex{"^[a-z]+$"}]] where Regex stores a string_view and the validator constructs a real std::regex at 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 since Range is 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
  • Outer call: pop address The 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 failure
  • collect — the existing shape. Unchanged behaviour, now takes an optional Mode.
  • checkstd::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 — throws ValidationException carrying the error list. For callers who treat validation failure as a genuine exceptional state. All three are implemented on top of collect:
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 av

User 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:

  1. Templates (detail::validate_impl, collect, check, validate) — implicitly inline. Each instantiation is vague-linkage; the linker de-duplicates.
  2. Struct/class member functions defined inlinedetail::ValidationContext::should_stop, ::current_path, ValidationException::ValidationException, ::what. All defined in-class, so implicitly inline.
  3. Non-template free functionsformat_error is the only one. It gets an explicit inline:
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.

  1. 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, no using at 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.cpp

Links 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::regex isn’t a literal class, so it can’t be an annotation value directly. The trick is to store the pattern as a std::string_view on the annotation and construct the std::regex at 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_v recursion) 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}]] where Validate stores 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_*.cppstages/08_*.cpp.

Current head: fc8870d — README, tests/smoke_test.cpp, ValidationContext moved into av::detail::.

EOF — 2026-04-18
> comments