RICCILAB
> blog/cpp26/one-declaration-two-outputs

One Declaration, Two Outputs

_DEV_C++26

The previous post closed with three candidate directions for Stage 20. The second one was the post with headroom:

Annotation-driven JSON Schema. Walk a type with a reflection visitor that maps each annotation to a JSON Schema constraint (Rangeminimum / maximum, MinLengthminLength, etc.) and emit a schema at compile time as a constexpr std::string. The same annotation becomes the source of both the validator and the contract document.

Stage 18 made every annotation own its validate(v, ctx). Stage 19 lifted that body into constexpr. Stage 20 asks: if an annotation can describe how to check a value, can the same annotation describe what shape a value has to have? One declaration, two outputs — a runtime validator and a JSON Schema.

The shape the stage is after:

struct User {
    [[=Range{0,150}]]                  int                        age;
    [[=MinLength{3},=MaxLength{64}]]   std::string                name;
    [[=NotNullopt{}]]                   std::optional<std::string> email;
    Address                                                        address;
    [[=MinSize{1},=MaxSize{10}]]       std::vector<std::string>   tags;
};

av::check(user)std::expected<void, vector<ValidationError>>.

av::json_schema<User>()constexpr std::string containing

{"type":"object","properties":{
  "age":{"type":"integer","minimum":0,"maximum":150},
  "name":{"type":"string","minLength":3,"maxLength":64},
  "email":{"type":"string"},
  "address":{"type":"object","properties":{
    "street":{"type":"string","minLength":2},
    "zip_code":{"type":"integer","minimum":1,"maximum":99999}}},
  "tags":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"string"}}},
 "required":["email"]}

Same walker, same annotations, same phase as Stage 19. Different projection.


The protocol already has the shape

Stage 18 wrote every annotation this way:

struct Range {
    long long min, max;
 
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const {
        if constexpr (requires { v < 0LL; } && !detail::is_optional_v<V>
                                             && !detail::is_vector_v<V>) {
            if (v < min || v > max) {
                ctx.errors.push_back({ctx.current_path(), /* message */, "Range"});
            }
        }
    }
};

The walker’s dispatch is a single requires probe:

if constexpr (requires { a.validate(v, ctx); }) {
    a.validate(v, ctx);
}

Nothing about that shape is specific to validation. It’s a plugin protocol: annotation owns its apply-site decision via if constexpr, walker calls through blindly. Any second projection on the same member can drop into the same ladder if it follows the same pattern.

The Stage 20 parallel:

struct Range {
    // … validate() as before …
 
    template <typename V>
    constexpr void schema_emit(detail::SchemaContext& sc) const {
        if constexpr (std::is_arithmetic_v<V> && !std::is_same_v<V, bool>) {
            std::string mn;  mn += "\"minimum\":"; mn += detail::to_str(min);
            sc.fragments.push_back(std::move(mn));
            std::string mx;  mx += "\"maximum\":"; mx += detail::to_str(max);
            sc.fragments.push_back(std::move(mx));
        }
    }
};

And the schema walker’s dispatch:

if constexpr (requires { a.template schema_emit<V>(sc); }) {
    a.template schema_emit<V>(sc);
}

Same requires gate. Same in-body if constexpr for type applicability. Same ctx-push style (sc.fragments.push_back(...) instead of ctx.errors.push_back(...)). The only asymmetry is the template: validate takes the value, schema_emit takes only the type, because a JSON Schema fragment doesn’t depend on a specific runtime instance — it’s a function of the annotation’s payload and the field type.

If the parallel holds mechanically at consteval — nested string concat, per-type dispatch, cross-cutting state — then the header change is essentially a transcription. Three probes first.

Probe 1 — nested JSON concat at consteval

Commit: e248e55

Stage 19 established std::to_chars + std::string at consteval. Before rebuilding the walker around schema emission, I wanted to know the composition doesn’t fall over at one more layer of nesting.

constexpr std::string emit_integer_schema(long long min, long long max) {
    std::string r;
    r += R"({"type":"integer","minimum":)";
    r += int_to_str(min);
    r += R"(,"maximum":)";
    r += int_to_str(max);
    r += '}';
    return r;
}
 
constexpr std::string emit_user_schema() {
    const std::string age_frag  = emit_integer_schema(0, 150);
    const std::string name_frag = emit_string_schema(3);
 
    std::string r;
    r += R"({"type":"object","properties":{)";
    r += emit_quoted("age");  r += ':'; r += age_frag;  r += ',';
    r += emit_quoted("name"); r += ':'; r += name_frag;
    r += R"(},"required":[)";
    r += emit_quoted("age");  r += ',';
    r += emit_quoted("name");
    r += "]}";
    return r;
}
 
static_assert(emit_user_schema() == std::string{
    R"({"type":"object","properties":{"age":{"type":"integer","minimum":0,"maximum":150},"name":{"type":"string","minLength":3}},"required":["age","name"]})"
});

Pass. String concat + to_chars + inline R"(...)" literals compose to a JSON Schema object through two nested layers, consteval evaluates it, static_assert pins the exact bytes. Nothing changed from Stage 19 — the mini-formatter just keeps working as the fragment sizes grow.

Probe 2 — type dispatch, and the floating-point question

Commit: e248e55

JSON Schema’s "type" keyword divides roughly into "integer" / "number" / "string" / "array" / "object" / "boolean". Most of these map cleanly from C++ types. The one that wasn’t obvious up front: double + Range{0,150}.

Range stores long long bounds. At runtime, Range::validate on a double v just compiles the comparison v < min || v > max with implicit integer-to-double promotion. That’s fine. But if the schema emitter treats Range as integer-only, double-fielded types silently lose their bound annotations. If it treats them honestly, it has to emit a different "type" keyword — "number" — and the numeric bounds convert from long long to whatever JSON Schema wants.

The experiment:

template <typename V>
    requires std::is_integral_v<V>
constexpr std::string emit(Range r) {
    std::string s;
    s += R"({"type":"integer","minimum":)";
    s += int_to_str(r.min);
    s += R"(,"maximum":)";
    s += int_to_str(r.max);
    s += '}';
    return s;
}
 
template <typename V>
    requires std::is_floating_point_v<V>
constexpr std::string emit(Range r) {
    std::string s;
    s += R"({"type":"number","minimum":)";
    s += int_to_str(r.min);
    s += R"(,"maximum":)";
    s += int_to_str(r.max);
    s += '}';
    return s;
}
 
static_assert(emit<int>(Range{0, 150}) ==
    std::string{R"({"type":"integer","minimum":0,"maximum":150})"});
static_assert(emit<double>(Range{0, 150}) ==
    std::string{R"({"type":"number","minimum":0,"maximum":150})"});

Pass. double + Range composes to {"type":"number","minimum":0,"maximum":150}. The bounds print as integers because Range is stored as long long; that’s true to the annotation, not a workaround. A hypothetical Range that stored double bounds would print fractional bounds the same way. JSON Schema accepts integer-valued numerics as "number" bounds — the schema is valid.

The decision: the header’s schema_emit<V> on Range gates on std::is_arithmetic_v<V> && !std::is_same_v<V, bool>. int and double and float and long all qualify. bool is arithmetic but excluded — bounds on a boolean field don’t mean anything in JSON Schema.

A bare-type dispatch check for the other cases:

template <typename V>
constexpr std::string emit_bare_type() {
    if constexpr (std::is_integral_v<V>)            return R"({"type":"integer"})";
    else if constexpr (std::is_floating_point_v<V>) return R"({"type":"number"})";
    else if constexpr (is_std_string<V>::value)     return R"({"type":"string"})";
    else if constexpr (is_std_vector<V>::value)     return R"({"type":"array"})";
    else                                            return "{}";
}

All branches resolve at consteval. std::vector<int> routes to "array". std::string isn’t is_aggregate_v-detectable as a scalar, so it’s matched with its own specialization.

Probe 3 — NotNullopt is structurally different

Commit: e248e55

Range, MinLength, MaxLength, NotEmpty, MinSize, MaxSize, Predicate are all field-local from the schema’s point of view: their contribution lives inside the member’s own JSON fragment.

NotNullopt isn’t. JSON Schema expresses “this field must be present” as an entry in the enclosing object’s "required" array. The member’s own fragment is unchanged. If email is std::optional<std::string> and has NotNullopt, the member emits {"type":"string"} like any other string, but the parent gets "required":["email"].

That’s a cross-cutting emission. The walker needs two buckets per member:

  1. Properties bucket — always contribute a fragment.
  2. Required bucket — contribute the member’s name iff NotNullopt is present in that member’s annotation pack. The mechanics, faked without reflection so the shape is visible:
struct MemberDesc {
    std::string name;
    std::string fragment;
    bool        required;
};
 
constexpr std::string emit_object(const std::vector<MemberDesc>& members) {
    std::string r;
    r += R"({"type":"object","properties":{)";
    for (std::size_t i = 0; i < members.size(); ++i) {
        if (i) r += ',';
        r += emit_quoted(members[i].name); r += ':'; r += members[i].fragment;
    }
    r += '}';
 
    std::vector<std::string> required_names;
    for (const auto& m : members) if (m.required) required_names.push_back(m.name);
 
    if (!required_names.empty()) {
        r += R"(,"required":[)";
        for (std::size_t i = 0; i < required_names.size(); ++i) {
            if (i) r += ',';
            r += emit_quoted(required_names[i]);
        }
        r += ']';
    }
    r += '}';
    return r;
}

Three scenarios pinned:

// Mixed.
static_assert(scenario_mixed() == std::string{
    R"({"type":"object","properties":{"age":{"type":"integer","minimum":0,"maximum":150},"nickname":{"type":"string"}},"required":["age"]})"
});
// All required.
static_assert(scenario_all_required() == std::string{
    R"({"type":"object","properties":{"id":{"type":"integer"},"email":{"type":"string"}},"required":["id","email"]})"
});
// None required — "required" key must be absent, not `"required":[]`.
static_assert(scenario_none_required() == std::string{
    R"({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"string"}}})"
});

Pass. Two concrete details got pinned by the third case: empty required lists omit the key entirely (JSON Schema treats "required":[] and absence identically, but the minimal form drops the key); and the order of required names is the member-declaration order, not the annotation-declaration order. That second point matters — static_assert(==) against a pinned literal cares about order, so the member walk and the required collection walk have to agree.

Three probes, three answers: composition holds, type dispatch handles floating-point cleanly, cross-cutting required works as a two-bucket pass. The header change is mechanical from here.

What lands in the header

Commit: 14830bc

Three things change in include/validator.hpp, all additive — no existing name loses a guarantee.

One: a SchemaContext. Sits next to ValidationContext in av::detail:

struct SchemaContext {
    std::vector<std::string> fragments;
    bool required = false;
};

fragments holds JSON key/value bodies without outer braces — "minimum":0 is one fragment, "maximum":150 is another. The outer walker is responsible for joining and wrapping. required is the cross-cutting flag NotNullopt flips.

Two: every annotation gains a schema_emit<V> member. One per annotation, all constexpr. A sample:

struct MinLength {
    std::size_t value;
    constexpr MinLength(std::size_t v) : value(v) {}
 
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const { /* Stage 18 shape */ }
 
    template <typename V>
    constexpr void schema_emit(detail::SchemaContext& sc) const {
        if constexpr (std::is_same_v<V, std::string>) {
            std::string f;
            f += "\"minLength\":";
            f += detail::to_str(value);
            sc.fragments.push_back(std::move(f));
        }
    }
};

The full contribution table:

AnnotationSchema contribution
Range"minimum":…,"maximum":… on arithmetic, not bool
MinLength"minLength":… on string
MaxLength"maxLength":… on string
NotEmpty"minLength":1 / "minItems":1
MinSize"minItems":… on vector
MaxSize"maxItems":… on vector
NotNulloptstructural — flips sc.required
Predicate<F>"$comment":"predicate: <message>"

Three: the schema walker + public entry. The structure mirrors walk_members / dispatch_value in Stage 19, with three helpers in between:

// Peel one layer of std::optional. JSON Schema has no "nullable"
// in draft-07 core, so optional<U> renders as U and presence is
// governed by whether NotNullopt was attached.
template <typename V>
using effective_t = /* V, unless V is std::optional<U>, then U */;
 
template <typename V>
constexpr std::string emit_type_keyword();       // `"type":"integer"`, …
 
template <typename V>
constexpr std::string emit_leaf_schema();        // bare `{"type":"…"}` for items
 
template <std::meta::info Member, typename V>
constexpr std::pair<std::string, bool>
emit_member_schema();                             // one member's `{...}` + required
 
template <typename T>
constexpr std::string emit_object_schema();      // full `{"type":"object", …}`

And the public entry:

template <typename T>
constexpr std::string json_schema() {
    if constexpr (std::is_aggregate_v<T> && !std::is_same_v<T, std::string>
                  && !detail::is_vector_v<T> && !detail::is_optional_v<T>) {
        return detail::emit_object_schema<T>();
    } else {
        return detail::emit_leaf_schema<T>();
    }
}

Same signature shape as passes / first_error — takes nothing but the type, returns a constexpr std::string. Callable at runtime to print a schema, or in a static_assert to pin exact bytes.

The test pins the bytes

Commit: 14830bc

The full User case:

struct User {
    [[=Range{0,150}]]                int                        age;
    [[=MinLength{3},=MaxLength{64}]] std::string                name;
    [[=NotNullopt{}]]                 std::optional<std::string> email;
    Address                                                      address;
    [[=MinSize{1},=MaxSize{10}]]     std::vector<std::string>   tags;
};
 
static_assert(json_schema<User>() == std::string{
    R"({"type":"object","properties":{)"
    R"("age":{"type":"integer","minimum":0,"maximum":150},)"
    R"("name":{"type":"string","minLength":3,"maxLength":64},)"
    R"("email":{"type":"string"},)"
    R"("address":{"type":"object","properties":{)"
    R"("street":{"type":"string","minLength":2},)"
    R"("zip_code":{"type":"integer","minimum":1,"maximum":99999})"
    R"(}},)"
    R"("tags":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"string"}})"
    R"(},"required":["email"]})"
});

That static_assert passes. So do the other five in the file: NotEmpty on string and vector, all-required object, no-required object, double + Range + Predicate, and the flat case. If any fragment drifts — if MinLength’s emit shape changes a byte, or the member order reorders — the diagnostic is the full-string diff clang prints for static_assert failures, with the offending run of bytes in context.

A sixth test is the runtime side: produce the schema at runtime, compare to the same literal, fail if it drifts. Both phases have to agree.

const auto rt = json_schema<User>();
if (rt != ct) { /* report and exit 1 */ }

Same bytes, same phases as in Stage 19. The validate/passes/first_error path renders the same messages at consteval and at runtime; the schema path does the same for the schema document.

Predicate as a marker — honest about the limit

Commit: 14830bc

The one annotation where this story forks is Predicate<F, N>. Its payload is a callable plus an N-sized char array of message. Runtime-wise, validate just calls f(v) and reports the message on false. Schema-wise, there’s nothing JSON Schema can do with an arbitrary C++ callable. You can’t export

[[=Predicate{[](int x){ return x%7==0;},"must be divisible by 7"}]]
int lucky_number;

into anything a JSON Schema validator will enforce. The body is opaque.

The honest move is to surface the existence of the constraint without pretending to enforce it. JSON Schema has "$comment" for exactly this — a metadata slot that validators ignore but humans and tooling see:

template <typename F, std::size_t N = 24>
struct Predicate {
    // …
    template <typename V>
    constexpr void schema_emit(detail::SchemaContext& sc) const {
        std::string f;
        f += "\"$comment\":\"predicate: ";
        f += std::string{message};
        f += '"';
        sc.fragments.push_back(std::move(f));
    }
};

So a field with Predicate{is_not_nan, "value must not be NaN"} shows up in the schema as

"reading":{"type":"number","$comment":"predicate: value must not be NaN"}

A downstream schema validator sees "type":"number" and checks it; sees "$comment" and moves on. A human reviewing the schema reads the comment and understands there’s an out-of-band rule. The C++ side still runs the predicate as a runtime check. Both sides have accurate information; neither is pretending the other didn’t happen.

That’s the point the "$comment" route makes. JSON Schema can’t express arbitrary callables, but it can express the fact that one exists. Admitting the limit, inside the mapping, is honest in a way that silently dropping the annotation wouldn’t be.

Why this closes the arc

Stage 17 was the observation that the dispatch ladder was a dispatch ladder. Stage 18 refactored it away — every annotation carries its own validate(); the walker calls through blindly. Stage 19 lifted the whole thing into constexpr — the same protocol runs at translation time.

Stage 20 is what that protocol was implicitly promising. If validate() is a decision about a value, and the walker never reads the annotation’s identity, then any per-member-per-annotation decision can ride the same shape. Schema emission is the first non-validation decision. It’s not the only one that could fit. A serialization visitor, a form-generator for a web UI, a CLI flag parser, a migration generator — anything that reads the annotation and the type, contributes per-member and optionally cross-cuts, follows the shape.

What made Stage 20 the closing piece rather than another waypoint is the specific pairing. A validator and its schema aren’t two separate problems — they’re the same constraint asked two ways:

  • Range{0, 150} on int age at runtime: “reject any age outside [0, 150].”
  • Range{0, 150} on int age as schema: “the contract is: age is in [0, 150].” Users writing one struct get both for free. No second declaration. No keeping-in-sync worry. If the schema drifts from the validator, one of them is wrong and the build knows it — the static_assert pins the schema to the annotation, and the runtime walker pins the validator to the same annotation. The annotation is the contract, and the contract has two consumers.

That’s what I wanted from annotation-reflective C++ two months ago, written as Stage 1. Stage 20 is that, compiled.


Freezing

This is the last Stage. The repo stops growing.

There are obvious next steps I’m not taking:

  • Regex fold — Stages 9–12 live as standalone .cpp files because no regex engine is constexpr yet. A runtime-gated Regex<N> in the header would round out the annotation set, and a "pattern":… contribution in schema_emit maps cleanly. But this is a header-only mechanical merge, not a design move. It’ll wait for P2996 to land in mainline clang/gcc, at which point a real library is worth building and this one won’t be it.
  • i18n — pluggable error formatters, keyed on annotation identity, context-threaded. The design is straightforward, the payoff is uninteresting as a post, and the scope is user-facing polish for a learning project.
  • Stage 8’s policy layer at constevalcollect / check / validate still go through std::format in a couple of spots (ValidationException::what(), format_error). They’re runtime-only because throwing doesn’t exist at consteval yet. Moving them would require replacing format there too. Possible, but the entry points that matter at consteval (passes, first_error, json_schema) already work, and the runtime path stays where it is. None of these are Stage-21 material. They’re refinements on the library surface.

What the repo is is a staged build log. Twenty stages, ten posts. Each stage a single-file compile, each post a single question and its answer. The arc — Stage 1 “can you read an annotation” to Stage 20 “the annotation is the contract” — is the story I wanted to tell about annotation-reflective design in C++26. Nothing more to say on that arc.

The header sits at a point where Stage 18’s protocol, Stage 19’s compile-time path, and Stage 20’s schema projection all coexist cleanly. If P2996 lands in mainline, this is a reference for how the pieces fit; it’s not a library to depend on. Library-ification is a different project, on a different timeline, for someone willing to commit to it.

Twenty stages, two outputs from one declaration, one header frozen. That’s the shape this was trying to reach. It’s reached.

EOF — 2026-04-20
> comments