· 6 min read ·

Compile-Time Format Strings Are Just the Beginning: What meta::substitute Really Unlocks

Source: isocpp

Barry Revzin has spent a substantial amount of time lately working through what string interpolation might look like in C++, and the details he’s shared on the isocpp blog are worth studying carefully. The headline capability is meta::substitute, part of the C++ reflection machinery coming in C++26 via P2996. Understanding why this is interesting requires stepping back from the string formatting story for a moment and looking at what C++ compile-time programming actually felt like before it.

The Old Shape of Compile-Time Metaprogramming

For most of C++‘s history, doing interesting things with types at compile time meant recursive template specializations. If you wanted to filter a parameter pack, you wrote something like this:

template <typename... Ts>
struct remove_const_types;

template <>
struct remove_const_types<> {
    using type = std::tuple<>;
};

template <typename T, typename... Rest>
struct remove_const_types<T, Rest...> {
    using rest = typename remove_const_types<Rest...>::type;
    using type = std::conditional_t<
        std::is_const_v<T>,
        rest,
        /* prepend T to rest somehow... */
    >;
};

And the “prepend T to rest somehow” part meant another template. The logic you are trying to express is simple: walk a list, filter it, rebuild a tuple from what remains. But the mechanism forces you to encode that loop as a type-level recursion, where each “iteration” is a template specialization. C++17 and C++20 improved this somewhat with fold expressions and if constexpr, but the fundamental shape remained: types as values, specializations as branching, recursion as looping.

consteval functions, introduced in C++20, helped with pure value computation at compile time. You could write an actual function with loops and conditions that ran entirely at compile time. What you still could not do was produce a new type from that computation. Consteval could tell you how many elements to keep; it could not hand you back a std::tuple<float, std::string> derived from filtering std::tuple<int, float, std::string>.

What meta::substitute Adds

This is precisely what meta::substitute addresses. In P2996’s model, reflections are values of type std::meta::info that represent types, functions, templates, constants, and other entities. You can manipulate them in ordinary consteval functions: collect them into vectors, filter them, sort them, pass them around. meta::substitute takes a reflection of a template and a vector of reflections representing template arguments, and produces a new instantiation.

The filtered-tuple example collapses to something much closer to what you would write in a dynamically-typed language:

consteval std::meta::info remove_const_types(std::meta::info tuple_type) {
    auto args = std::meta::template_arguments_of(tuple_type);
    std::vector<std::meta::info> filtered;
    for (auto arg : args) {
        if (!std::meta::is_const_type(arg)) {
            filtered.push_back(arg);
        }
    }
    return std::meta::substitute(^^std::tuple, filtered);
}

The ^^ prefix is the reflection operator from P2996 (earlier drafts used ^). template_arguments_of gives you the arguments as a range of info values. You iterate, filter, collect, substitute. This is a loop, not a recursion. It reads like data processing code because it is data processing code, just running at compile time on type metadata.

The mechanical improvement over the old approach is real, but the more important change is conceptual. Once you can build types from procedural logic, the designs you can express start looking very different. Revzin’s string interpolation work is a good demonstration.

Format Strings as Compile-Time Programs

Standard std::format already does some work at compile time. Since C++23, format string validity is checked at compile time: if you write std::format("{} {}", x) with only one argument, the compiler catches it. But the parsing stops there. The format string is validated, but it is not transformed or analyzed further; the actual formatting logic runs at runtime with the parsed format string.

Revzin’s string interpolation proposal pushes much further. A format string like "{x} and {y} and {z}" can be fully parsed at compile time via a consteval function: extract which arguments are referenced, in what order, with what format specifications. Once you have that information as a list of meta::info values, you can use meta::substitute to build a formatter type that is specialized for exactly this format string. The substitution lets you encode the structure of the format string into the type system without any runtime dispatch.

The Highlighting Example

The most striking example in Revzin’s writeup is a highlighting algorithm. Given the input variables x = 5, y = 10, z = "hello", a plain format string would produce "5 and 10 and hello". But a highlighting formatter can produce something like "5 and y=*10* and z=hello", where only certain interpolated values get wrapped in markers, based on logic that is resolved entirely at compile time.

This is possible because the format string is a compile-time constant. You can parse it at compile time, determine which arguments should be highlighted based on some compile-time-evaluable criteria, and generate code that applies the highlighting only in those positions. The resulting code has no branching on format string structure at runtime; the structure was baked into the generated type via meta::substitute.

Revzin notes this was not possible with a competing design for string interpolation. The alternative would not guarantee the format string was available as a compile-time constant at the analysis stage. Without that guarantee, you cannot parse it, so you cannot selectively apply transformations, so the highlighting algorithm cannot be expressed at all. The design constraint that the format string must be a compile-time constant is not an accidental limitation; it is load-bearing for this entire class of capabilities.

How This Compares to Other Languages

Rust gets partway here through procedural macros. format! and friends like tracing::event! parse their format strings at compile time inside the macro expansion step, and they do enforce structure, but the mechanism is a separate compilation phase with its own language (the macro author writes Rust code that manipulates token streams, not the language’s native type system). You cannot write a Rust function that returns a type derived from analyzing a string; you write a macro that generates code.

D’s compile-time function evaluation (CTFE) is closer in spirit. D allowed functions to run at compile time and their results to influence types, which made certain metaprogramming tasks much more fluid than in C++. But D never achieved widespread adoption, partly because the broader ecosystem did not materialize around it.

Zig’s comptime is probably the closest analogue in spirit. In Zig, any expression can be marked comptime, and the language makes no strong distinction between “a type” and “a value of type type”. You can write functions that return types, pass types as arguments, and do all of this with ordinary control flow. It makes the language feel more coherent for metaprogramming, though it achieves this by making types first-class values in a way C++ is only now approximating through reflections.

What C++ is building is not exactly like any of these. Reflection via meta::info keeps the type system and the runtime world distinct; you work with reflections of types rather than types as values directly. meta::substitute is the bridge that lets you turn a computed list of reflections back into a usable type. It is a more explicit mechanism than Zig’s approach but more integrated than Rust’s procedural macros, and it works within C++‘s existing type system rather than requiring a redesign.

The Broader Pattern

Revzin’s observation that this work happened an hour at a time during his daughter’s nap is the kind of detail that makes you appreciate how much of C++‘s evolution happens through individual contributors just working through ideas carefully. The implementation work for string interpolation was apparently a tractable project for focused small sessions, which says something about the composability of the P2996 facilities once they are available.

The deeper point is that meta::substitute does not just make one thing possible. Any problem that currently requires elaborate recursive template specializations to produce types from computed lists is a candidate for being rewritten into something readable. Type-level algorithms that iterate over argument packs, filter them by traits, transform them, and build new compound types from the result, all of this becomes expressible in ordinary procedural style.

C++ has always had the capability to do significant computation at compile time, but the ergonomics were severe enough that it remained largely the domain of library authors and committee members. Reflection and meta::substitute do not change what is theoretically possible, but they change what is practically approachable. That is a meaningful shift, and Revzin’s string interpolation work is a clean illustration of where it leads.

Was this interesting?