From Template Recursion to Procedural Code: What meta::substitute Changes for C++ Metaprogramming
Source: isocpp
Barry Revzin has been working on string interpolation for C++, and a recent writeup on the isocpp blog illustrates why meta::substitute, one of the core operations in the P2996 static reflection proposal, matters beyond just the formatting use case. The string formatting story is useful context, but the deeper point is about what kind of compile-time programming becomes possible when you can build types from procedural logic.
The Problem That Required Recursion
For most of C++‘s history, constructing a type from a computed list of other types meant recursive template specializations. Consider filtering a parameter pack down to only non-const types and packing the results into a std::tuple. The intent is simple: walk the pack, skip const-qualified entries, collect the rest. The implementation looked like this:
template <typename... Ts>
struct filter_non_const;
template <>
struct filter_non_const<> {
using type = std::tuple<>;
};
template <typename T, typename... Rest>
struct filter_non_const<T, Rest...> {
using rest_type = typename filter_non_const<Rest...>::type;
using type = std::conditional_t<
std::is_const_v<T>,
rest_type,
/* somehow prepend T to rest_type */
>;
};
The prepend step requires another template. The logic you want is a loop with a conditional; the mechanism you are forced to use is type-level recursion with one specialization per “iteration.” C++17’s if constexpr and fold expressions improved certain cases, but the fundamental limitation persisted: you could compute values at compile time with constexpr functions, but turning those values back into types required the recursive template machinery.
consteval in C++20 tightened the guarantee that certain functions run exclusively at compile time, but it did not close the gap. A consteval function could tell you that you should keep three out of five types; it could not produce std::tuple<float, std::string, int> as a return value.
What meta::substitute Provides
meta::substitute, as specified in P2996, takes a reflection of a template and a list of reflections representing template arguments, and returns a reflection of the resulting instantiation. The signature is approximately:
namespace std::meta {
consteval info substitute(info templ, std::span<const info> args);
}
In P2996, the ^^ prefix operator (earlier drafts used ^) reflects an entity into a value of type std::meta::info. These info values are copyable, storable in containers, and manipulable in ordinary consteval functions. meta::substitute is the operation that converts a list of those reflections back into a concrete template instantiation.
The filtered-tuple problem collapses:
consteval std::meta::info filter_non_const(std::meta::info tuple_type) {
auto args = std::meta::template_arguments_of(tuple_type);
std::vector<std::meta::info> kept;
for (auto arg : args) {
if (!std::meta::is_const_type(arg)) {
kept.push_back(arg);
}
}
return std::meta::substitute(^^std::tuple, kept);
}
That is a loop with a push_back, not a recursion. The code reads like data processing because it is data processing, running on type metadata at compile time. The ^^std::tuple reflects the tuple template itself; kept is a runtime vector within the consteval context; substitute performs the instantiation.
std::format’s Compile-Time Ceiling
std::format, standardized in C++20 via P0645 and improved in C++23, made format string validation a compile-time operation. Write std::format("{} {}", 42) with one argument short and the compiler rejects it. The mechanism is a consteval constructor on std::format_string<Args...> that parses the format string and checks it against the argument types.
But that validation is internal to the standard library. The parsed structure is consumed and discarded. Nothing about the format string’s content, how many slots there are, what positions they occupy, whether any carry special modifiers, is accessible to calling code. You cannot write a library on top of std::format that does anything more interesting than what std::format itself does, because the compile-time information is not exposed.
Revzin’s string interpolation work pushes on this boundary. The proposal parses a format string in a consteval function and exposes the parsed structure as a sequence of meta::info values. From there, meta::substitute lets you build a formatter type whose template arguments encode the complete structure of the format string. The compiler sees a specific type for each distinct format string, generates specialized code for it, and the runtime overhead is exactly what hand-written code would produce.
The Highlighting Example
The concrete example Revzin uses to motivate this is a highlighting algorithm. Given arguments x = 5, y = 10, z = "hello", a format string that marks y for highlighting would produce:
5 and y=*10* and z=hello!
Only y’s value is wrapped in markers. This requires the formatter to know, at compile time, which argument at which position should be treated differently. With a runtime format string you end up parsing it at runtime, dispatching per-slot through some mechanism, and paying overhead proportional to the number of slots on every call.
With a compile-time format string and meta::substitute, you parse once, build a type whose template arguments encode the per-slot policies, and compile to specialized code. The slot that gets highlighted is not selected at runtime; it is encoded in the type. There is no branch, no dispatch, no interpretation loop.
Revzin notes in the article that this was not achievable with a competing design for string interpolation. The alternative design did not guarantee the format string was available as a compile-time constant at the parsing stage. Without that guarantee you cannot extract structure, so you cannot feed that structure into meta::substitute, so the per-slot policies become a runtime concept rather than a compile-time one. The compile-time constant requirement is not an incidental restriction; it is what makes the entire class of compile-time transformations possible.
Comparison with Other Languages
Rust’s format! macro validates format strings at compile time, but through a built-in compiler mechanism rather than a user-extensible API. The Rust compiler itself implements the format string parsing; library authors cannot write an equivalent from scratch using the public language. Procedural macros in Rust get you further, letting you operate on token streams during compilation, but you are writing a separate crate in a separate compilation step. The macro author works with token trees, not the type system directly, and the result is code generation rather than type construction from within user code.
Zig’s comptime is closer in spirit. Types are first-class values in Zig; you can write functions that return types and call them with ordinary function-call syntax. The distinction between “a type” and “a value that is a type” mostly disappears. This makes Zig metaprogramming considerably more ergonomic than C++ currently is, but it achieves that by redesigning how types work in the language rather than layering reflection onto an existing system.
D’s compile-time function evaluation (CTFE) allowed functions to influence types at compile time and was genuinely ahead of its time. The ecosystem never grew around it at scale, but the design ideas were sound, and parts of what C++ is building now were already expressible in D years ago.
What C++ reflection provides is access to these capabilities inside the existing type system, through consteval functions that look like ordinary C++ code, without requiring a macro system boundary or a language redesign. meta::substitute is the specific operation that bridges computed metadata back into the type system, and it maps cleanly onto something the compiler already does internally (template instantiation), now exposed as a first-class compile-time API.
The Broader Pattern
String formatting makes a good demonstration case because the problem is universally familiar, but the pattern generalizes. A SQL query builder that parses a query string at compile time and derives a strongly-typed result-row type from the projected columns follows the same shape. So does a serialization library that generates optimized encoders per struct layout without macros, or a command dispatch framework built from reflected function signatures rather than explicit registration. I find myself wanting something like this for Discord bot command routing: parse a command schema at compile time, derive the argument tuple type from the schema structure, generate dispatch code with no runtime reflection or string matching.
In each case: parse structured input in a consteval context, produce a list of meta::info values from the parse results, call meta::substitute to assemble a concrete type, work with that type at runtime at zero overhead.
Revzin mentions working through the string interpolation implementation an hour at a time during his daughter’s nap. That detail is worth sitting with. The same work attempted five years ago, before reflection tooling existed, would have required days of stacking recursive templates, SFINAE tricks, and std::apply-style indirection. The reflection API reduces it to something you can iterate on in focused short sessions. When the tools become approachable enough for that kind of casual exploration, more people explore, more edge cases surface, and the designs that emerge are better tested.
P2996 is still working through the standardization process. C++26 is the expected landing target, with experimental implementations available in Clang and EDG front-ends. meta::substitute is one of the more stable parts of the design given how directly it maps to existing compiler internals. The string interpolation work Revzin describes is as much a demonstration that the P2996 API is expressive enough to support real library design as it is a proposal for a specific feature. Both conclusions hold up.