How Clang's Sema Turned C++20 Concept Errors from Riddles into Readable Diagnostics
Older Clang reported only that a C++20 concept failed. Modern Sema walks the constraint tree and names the exact sub-expression that broke — here's what changed, with a worked example and the compile-time trade-offs.
19 Sept 2025, 09:14 UTC

If you compiled C++20 code with an older Clang and a concept failed, you got something like: error: constraints not satisfied for 'add' with 'std::vector<int>'. Full stop. No hint about which requirement failed, no pointer to the missing operator. For a concept with half a dozen nested requirements, you were left bisecting the constraint by hand.
The fix landed inside Sema, Clang's semantic analysis component — the part of the compiler that checks whether your code actually means something after parsing. Instead of evaluating a concept as a single true/false black box, modern Clang (roughly 15.0 onward) walks the constraint expression tree and emits a diagnostic for each atomic requirement that fails. This post explains what changed, shows a concrete before/after, and covers the trade-offs you should know about.
Why concept checking was a black box
A concept like Addable<T> is, internally, a tree of constraint expressions: top-level conjunctions, nested requires expressions, and individual atomic constraints such as { a + b } -> std::same_as<decltype(a + b)>. Early implementations evaluated this tree the way you'd evaluate any boolean expression — short-circuit to false, report the outermost failure, done.
That's fine for codegen, where only the verdict matters. It's terrible for humans. The compiler knew which sub-expression failed — it had just thrown that information away. The engineering decision was to keep it: when constraint satisfaction fails, Sema now re-walks the tree in a diagnostic mode, re-evaluating each atomic constraint and attaching a note to the one that broke.
A worked example
Save this as concept.cpp and compile with clang++ -std=c++20 concept.cpp (Clang 15 or newer; no special permissions needed, and compilation produces no output file for failing code, so nothing to clean up):
#include <concepts>
#include <vector>
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<decltype(a + b)>;
};
template<Addable T>
T add(T a, T b) { return a + b; }
int main() {
std::vector<int> x, y;
add(x, y); // no operator+ for std::vector<int>
}With older Clang, you'd see a single error stating that Addable<std::vector<int>> is not satisfied. With the enhanced Sema, the diagnostic chain drills down: the requires expression failed because a + b is not a valid expression, because no operator+ overload matches std::vector<int>. The note points at the exact sub-expression, so the fix — "vectors don't support +; did you mean element-wise addition?" — is obvious without opening the concept definition.
To verify the behavior on your own toolchain, compile the same file with an older Clang (e.g., 12.0) and compare outputs. You can also add -fdiagnostics-show-template-tree to see the expanded instantiation stack and confirm the notes originate from the constraint walk rather than generic template-diff machinery.
What the extra traversal costs
Nothing is free. The diagnostic re-walk only triggers on failure, but in codebases with heavy concept usage and lots of near-miss overload candidates, constraint failures are routine during overload resolution — every discarded candidate is a failed constraint. Re-walking those trees adds measurable compile-time overhead, on the order of 5–10% in concept-heavy translation units. For most projects that's invisible; for a template-heavy header library instantiated thousands of times, it's worth benchmarking before and after a compiler upgrade.
There's also a correctness subtlety: dependent expressions in templates that are never instantiated must not produce false-positive diagnostics. Sema has to distinguish "this constraint genuinely failed for these concrete arguments" from "this expression is still dependent and can't be evaluated yet." Getting that boundary wrong would mean spurious errors in perfectly valid generic code, so the diagnostic walk is deliberately conservative.
Limitations worth knowing
- Definitions must be visible. If a template involved in the constraint is only declared, not defined, at the point of checking, Sema can't evaluate it and falls back to a generic message. Keep concept-related templates in headers.
- Output volume. Detailed notes are great in a terminal but can flood an IDE's error panel. If your editor drowns in notes, configure diagnostic filtering or collapse note-level severity.
- Version dependence. The depth of these notes has improved incrementally across releases. Don't assume Clang 15 and Clang 18 produce identical output; check against the version you actually ship in CI.
Takeaway
The practical lesson isn't just "newer Clang has nicer errors." It's that constraint failures are now debuggable: when a concept rejects your type, read the innermost note first — it names the exact operator, trait, or constexpr check that failed. If you're maintaining a library with public concepts, compile your negative test cases with a recent Clang and look at what your users will see; you may find your concept definitions deserve restructuring so the diagnostics land on the requirement people are most likely to get wrong.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.