RICCILAB
> blog/cpp26/static-assert-reads-the-error

Static Assert Reads the Error

_DEV_C++26

The previous post closed with a concrete next step:

Constexpr validation. Every validate() member is already marked constexpr. ValidationContext isn’t, because it holds std::vector<ValidationError>. Replacing it with a constexpr-friendly collector (a fixed-capacity array, or a compile-time-appendable buffer through std::define_static_array tricks) would let a struct User u{…}; literal be checked against its annotations at compile time, producing a static_assert-equivalent failure if any annotation rejects.

The good news: the std::define_static_array tricks turned out not to be necessary. C++20’s transient allocations let std::vector<ValidationError> live inside a consteval context as long as it’s gone by the time the evaluation ends, and C++26 clang-p2996 honors that. The actual work was in four smaller questions:

  1. Can std::format run at constant evaluation?
  2. Can the walker itself run at constant evaluation?
  3. If std::format can’t, what can produce the same message bytes?
  4. When static_assert fails, does clang-p2996 embed a computed std::string in its diagnostic? The answers pin down a shape where user code looks like
constexpr User alice{.age = 30, .name = std::string{"Alice"},
                     .address = Address{.street = std::string{"Main St"},
                                        .zip_code = 12345}};
 
static_assert(av::passes(alice), av::first_error(alice));

and a bad literal produces

error: static assertion failed: age: must be in [0, 150], got 200 (Range)

at translation time, with no runtime side. The same validate() members run. The same annotation structs apply. The same message bytes come out. Different phase.


Probe 1 — does std::format work at consteval?

Commit: fd89528

The existing annotations look like this:

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(),
                    std::format("must be in [{},{}], got{}", min, max, v),
                    "Range"
                });
            }
        }
    }
};

validate is already constexpr. The obvious hope is that marking the walker constexpr, calling it from a consteval driver, and asserting on ctx.errors.empty() just works. The one non-constexpr call in that body is std::format. P2286 made std::format specified as constexpr, but specification and implementation are different things. The experiment:

// experiments/probe_19_const_format.cpp
consteval std::string format_in_consteval() {
    return std::format("must be in [{},{}], got{}", 0, 150, 200);
}
static_assert(format_in_consteval() == std::string{"must be in [0, 150], got 200"});

The result on this clang-p2996 build:

error: non-constexpr function 'format<int, int, int>' cannot be used in a constant expression

So the libc++ shipped with this fork hasn’t caught up to P2286 yet. That’s fine as a fact — but it closes off the naive path. Every annotation message would have to come from somewhere else if compile-time validation is going to produce the same strings the runtime walker does.

Probe 2 — does the walker itself run at consteval?

Commit: fd89528

Pulled in the other direction: set std::format aside (annotations push fixed strings like "out of range"), keep everything else. Does the walker’s actual machinery cooperate?

The moving parts are:

  • template for (constexpr auto member : std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx)))
  • Reflection splices (obj.[:member:])
  • std::meta::extract<A>(ann) to pull the annotation value out
  • Transient std::vector<ValidationError> inside a consteval context
  • Aggregate recursion through std::is_aggregate_v<V>
// experiments/probe_19_const_walk.cpp
consteval std::size_t count_errors_bad() {
    User u{200, std::string{"al"}, Address{std::string{"X"}, 0}};
    ValidationContext ctx;
    walk_members(u, ctx);
    return ctx.errors.size();
}
 
static_assert(count_errors_bad()  == 4);
static_assert(count_errors_good() == 0);
static_assert(nested_path_ok());  // "address.street" / "address.zip_code"

Pass. The walker itself is fine at consteval — template for, std::define_static_array, reflection splices, std::meta::extract, and the transient vector all compose without complaint. The blocker is precisely where Probe 1 hit: std::format. If messages came from somewhere else, nothing else would need to move.

That narrows the problem to: find a constexpr way to produce the exact same bytes std::format produces at runtime.

Probe 3 — std::to_chars as a std::format replacement

Commit: fd89528

std::to_chars became constexpr for integral types in C++23 via P2291. All the existing annotation messages are integrals plus literal separators — min, max, v, value, v.size(). A two-line helper does the work:

constexpr std::string to_str(long long x) {
    char buf[24];
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), x);
    return std::string(buf, ptr);
}
 
constexpr std::string to_str(std::size_t x) {
    char buf[24];
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), x);
    return std::string(buf, ptr);
}

Range’s validate body becomes:

if (v < min || v > max) {
    std::string msg;
    msg += "must be in [";
    msg += detail::to_str(min);
    msg += ", ";
    msg += detail::to_str(max);
    msg += "], got ";
    msg += detail::to_str(static_cast<long long>(v));
    ctx.errors.push_back({ctx.current_path(), std::move(msg), "Range"});
}

Ugly next to std::format("must be in [{}, {}], got {}", min, max, v), but it’s specifically ugly in a way that doesn’t matter — the bytes coming out are:

must be in [0, 150], got 200

Byte-identical. Same for MinLength, MinSize, MaxSize, MaxLength. The proof:

// experiments/probe_19_to_chars_format.cpp
consteval std::string t3() { return make_range_msg(0, 150, 200); }
static_assert(t3() == std::string{"must be in [0, 150], got 200"});

That static_assert passes. Runtime output from Stage 18’s walker, written to stdout, matches it literally:

age: must be in [0, 150], got 200 (Range)

One more thing in the same file: use the mini-formatter-based annotation inside a consteval walker driver end-to-end.

consteval bool end_to_end() {
    User u{200, std::string{"al"}};
    ValidationContext ctx;
    walk_members(u, ctx);
    if (ctx.errors.size() != 2) return false;
    if (ctx.errors[0].message != "must be in [0, 150], got 200") return false;
    if (ctx.errors[1].message != "length must be >= 3, got 2")   return false;
    return true;
}
static_assert(end_to_end());

Pass. The consteval walker runs; the messages come through byte-for-byte the same as the runtime walker.

ValidationContext::current_path() had the same issue — it used std::format("[{}]", index) to render container indices. Same fix, same bytes:

constexpr std::string current_path() const {
    std::string r;
    for (const auto& seg : path_stack) {
        if (std::holds_alternative<std::size_t>(seg)) {
            r += '[';
            r += to_str(std::get<std::size_t>(seg));
            r += ']';
        } else {
            const auto& name = std::get<std::string>(seg);
            if (!r.empty()) r += '.';
            r += name;
        }
    }
    return r;
}

users[2].address.zip_code renders the same whether the walker ran at runtime or in a constant expression.

Probe 4 — does static_assert surface a computed std::string?

Commit: fd89528

P2741 (C++26) extended static_assert’s second argument from a string literal to any constant-expression-producing std::string or std::string_view. Specified, but I didn’t want to find out at the header-migration stage that the compiler accepts the syntax and then emits only a generic “static assertion failed” on failure. The marker test:

// experiments/probe_19_static_assert_msg_fail.cpp
constexpr std::string make_msg() {
    return std::string{"MAGIC_MARKER_STRING: constexpr std::string reached diagnostic"};
}
static_assert(false, make_msg());

Compile it and grep MAGIC_MARKER in the stderr:

error: static assertion failed: MAGIC_MARKER_STRING: constexpr std::string reached diagnostic

There it is, embedded in the diagnostic. Same check with string_view and literal: both surface their content too. The second argument to static_assert is fully plumbed — whatever first_error(obj) returns will end up in the compile error.

Four probes, four answers: std::format is blocked at consteval (Probe 1), the walker isn’t (Probe 2), std::to_chars produces byte-identical messages (Probe 3), static_assert will render whatever string we compute (Probe 4). The path A I thought was closed is open through a short detour.

What lands in the header

Commit: 0bd2af9

Only three things really change in include/validator.hpp.

One: a pair of to_str overloads. Added inside av::detail, right before ValidationContext.

constexpr std::string to_str(long long x) {
    char buf[24];
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), x);
    return std::string(buf, ptr);
}
constexpr std::string to_str(std::size_t x) {
    char buf[24];
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), x);
    return std::string(buf, ptr);
}

Two: every annotation’s std::format line rewrites to to_str + concat. Mechanical. Range and MinLength shown above; MaxLength, MinSize, MaxSize are the same pattern with different literal fragments. NotEmpty, NotNullopt, Predicate didn’t use std::format at all — fixed-string or char[N] messages already.

Three: constexpr on walker members and new entry points. walk_members<T>, dispatch_value<Member, V>, ValidationContext::current_path(), ValidationContext::should_stop() — all gain the keyword. Then:

template <typename T>
constexpr bool passes(const T& obj) {
    detail::ValidationContext ctx;
    detail::walk_members(obj, ctx);
    return ctx.errors.empty();
}
 
template <typename T>
constexpr std::string first_error(const T& obj) {
    detail::ValidationContext ctx;
    detail::walk_members(obj, ctx);
    if (ctx.errors.empty()) return {};
    const auto& e = ctx.errors.front();
    std::string r;
    r += e.path;    r += ": ";
    r += e.message; r += " (";
    r += e.annotation; r += ")";
    return r;
}

Two new names in av::. Both constexpr, both also usable at runtime. That’s the entire compile-time surface.

What doesn’t move: the runtime policy layer. collect / check / validate still go through the same walker but stay non-constexpr, because validate throws a ValidationException whose what() message goes through std::format in its constructor, and format_error uses std::format for its own renderer. Throwing at compile time isn’t a thing yet, and pushing std::format out of those two spots would cost more than it buys. The compile-time path is additive: two new names, zero regressions.

Usage style A — namespace-scope constexpr

Commit: fd89528

The short form is the one that reads well as documentation:

constexpr User alice{
    .age = 30,
    .name = std::string{"Alice"},
    .address = Address{.street = std::string{"Main St"},
                       .zip_code = 12345}
};
 
static_assert(av::passes(alice), av::first_error(alice));

alice is a User at namespace scope. passes(alice) walks it at consteval, collects zero errors, returns true. The static_assert compiles clean. Flip any field to something that violates a constraint and the same assertion now fails; first_error(alice) computes the error string, and the compiler reads it out:

constexpr User bad{.age = 200, .name = std::string{"Alice"},
                   .address = Address{.street = std::string{"Main St"},
                                      .zip_code = 12345}};
static_assert(av::passes(bad), av::first_error(bad));
error: static assertion failed: age: must be in [0, 150], got 200 (Range)

There’s a constraint on this form the stage file’s comments have to be explicit about. C++20 permits transient allocations inside a constant expression — but only transient. A constexpr T obj{…} at namespace scope stores the final state of obj into the program image. If any member still holds heap storage at that point, it isn’t a constant expression. For std::string, short-string optimization makes the difference: libc++ stores up to 22 characters inline in the string’s own bytes on 64-bit. "Alice" (5 bytes) and "Main St" (7 bytes) both fit SSO. Flip "Main St" to a 25-character street name and the declaration stops being constexpr, because the finalized std::string needs heap bytes.

That’s a real constraint, not a sharp edge to engineer around. If your struct declarations are doc-example-shaped — short names, small zip codes, canonical IDs — Style A just works.

Usage style B — consteval helper for vectors

std::vector<T> doesn’t have an SSO escape hatch. Every non-empty vector heap-allocates. So you can’t write:

constexpr Team t{.members = std::vector<User>{/* … */}};   // not a constant expression

But you can construct that vector inside a consteval function, walk it there, and return a bool or a std::string. Those return values are transient-allocated values the function is returning — they just have to be used promptly by the caller, and static_assert’s argument is promptly enough:

consteval bool team_passes() {
    Team t{.members = std::vector<User>{
        User{.age = 30, .name = std::string{"Alice"},
             .address = Address{.street = std::string{"Main St"},
                                .zip_code = 12345}},
        User{.age = 45, .name = std::string{"Bob"},
             .address = Address{.street = std::string{"Broadway"},
                                .zip_code = 10001}},
    }};
    return av::passes(t);
}
 
consteval std::string team_err() {
    Team t{/* same */};
    return av::first_error(t);
}
 
static_assert(team_passes(), team_err());

team_passes() and team_err() both run at the translation unit’s constant-evaluation phase. t exists only inside them; the vector’s transient heap is released before the function returns; what escapes is a bool or (for std::string returning from a consteval function) the string’s content, reconstituted at the caller. When the team validates clean the assertion compiles; when it doesn’t, the computed error string reaches the diagnostic the same way Style A does.

The rule for picking between them comes down to whether your type’s members can sit at namespace scope without holding heap. Aggregate structs of scalars and short strings are fine; anything with std::vector, std::map, or long strings needs a consteval wrapper.

One subtle point — consteval results can’t escape to runtime

There’s one thing I tried and it doesn’t work:

int main() {
    const std::string ct_msg = team_err();   // consteval std::string → runtime const
    std::cout << ct_msg;
}

clang-p2996 rejects this with “pointer to subobject of heap-allocated object is not a constant expression”. The intuition: at the point where team_err() is being evaluated, the returned std::string holds bytes on a transient consteval heap. Binding that to a runtime variable would require the bytes to still exist at runtime, but they don’t — the transient heap was cleaned up at the end of the consteval evaluation.

This doesn’t matter for static_assert(condition, message), because the second argument is consumed by the compiler at consteval time — it renders straight into the diagnostic and is never a runtime value. But if you’re testing the compile-time and runtime message paths for drift and try to assign the consteval result to a std::string variable to compare, you’ll hit this wall. The workaround in the test file is simple: compare both ends against the same literal.

static_assert(bad_user_first_error()
              == std::string{"age: must be in [0, 150], got 200 (Range)"});
 
// and at runtime:
const std::string rt_msg = av::first_error(bad);
if (rt_msg != "age: must be in [0, 150], got 200 (Range)") return 1;

Both sides pin to the same expected string. Any drift breaks at least one.

A user-defined annotation, at compile time

Commit: 0bd2af9

The protocol from Stage 18 was: a user can define a new validator by writing a plain struct with a validate() member. That was always runtime-facing. It carries into the compile-time path with no further work. Example:

struct StartsWithUppercase {
    template <typename V, typename Ctx>
    constexpr void validate(const V& v, Ctx& ctx) const {
        if constexpr (requires { v.size(); v[0]; }
                   && !av::detail::is_optional_v<V>
                   && !av::detail::is_vector_v<V>) {
            if (v.empty() || v[0] < 'A' || v[0] > 'Z') {
                ctx.errors.push_back({
                    ctx.current_path(),
                    "must start with uppercase letter",
                    "StartsWithUppercase"
                });
            }
        }
    }
};
 
struct Account {
    [[=StartsWithUppercase{},=av::MinLength{3}]]  std::string username;
    [[=av::Range{0,120}]]                         int         age;
};
 
consteval std::string err() {
    Account a{.username = std::string{"root"}, .age = 42};
    return av::first_error(a);
}
static_assert(err() == "username: must start with uppercase letter (StartsWithUppercase)");

That static_assert passes. StartsWithUppercase was written entirely outside the library header. Its validate is constexpr. The walker finds it, calls it via requires { a.validate(v, ctx); }, and its message reaches the diagnostic string — exactly the same way Range’s does.

The protocol is open in both phases.

What it’s for

The obvious one: configuration literals. Teams that encode feature flags, environment defaults, or dimensional constants as constexpr structs can pin their invariants as static_assert(av::passes(cfg), av::first_error(cfg)) next to the declaration. Something invalid doesn’t build; the error message points directly at the field and the constraint.

The less obvious one: it makes the annotations’ semantics self-documenting in the build. If a field says [[=Range{1, 99999}]] int zip_code, a test that instantiates a constexpr literal with a .zip_code = 100000 produces a specific compile error about that constraint. No test runner, no runtime path — just clang++. The constraint on the field, the evidence it does what it says, and the failure mode are all the same two files.

And the meta-one: the same walker, the same annotations, the same message bytes, run in a phase where there’s no runtime at all. Stage 8 was the library. Stage 18 was the protocol. Stage 19 is the demonstration that when the protocol is constexpr, translation itself becomes a checker. Nothing new has to be said or specified. The existing shape already works.

Next

Stage 20 closes the arc: annotation-driven JSON Schema export.

If an annotation carries its own validate(), it can carry its own schema_fragment<V>() just as easily. Range{0, 150} on an int field contributes "type":"integer","minimum":0,"maximum":150 to the generated schema alongside running its runtime bounds check. MinLength{3} contributes "minLength":3. NotNullopt{} contributes nothing at the field itself — it flips the parent’s required list. Predicate<F, N> can’t export, because an arbitrary callable isn’t expressible in JSON Schema; the honest answer is a "$comment" marker.

Same walker, same annotations, different projection. static_assert(av::json_schema<User>() == R"(…)") pins the output the same way Stage 19 pins first_error. One declaration, two outputs.

After that, this repo stops growing. It stays as a staged build log + blog series — a reference for how annotation-reflective design works in C++26, not a library to depend on. Library-ification waits on P2996 landing in mainline clang/gcc, and is a separate project on a separate timeline.

EOF — 2026-04-20
> comments