· 5 min read ·

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

Source: isocpp

C++ has been slowly, deliberately building toward something that languages like Python and Rust have had for years: expressive, type-safe string interpolation. But the route C++ is taking, via static reflection and compile-time metaprogramming, ends up somewhere considerably more powerful than where those languages currently sit. Barry Revzin’s recent deep dive on meta::substitute is a good illustration of why.

The Long Road from printf to Compile-Time Format Strings

The history here matters. C’s printf is fully runtime: the format string is just a const char*, type safety is entirely your problem, and the compiler can only warn you if it’s feeling generous. boost::format improved type safety by overloading operator% to build up argument lists, but it paid for that with heap allocation and runtime parsing on every call.

std::format, standardized in C++20 via P0645, made the first real leap. The format string becomes a compile-time constant, and the library validates it against the argument types before your program runs. If you write std::format("{} {}", 42) with one argument missing, you get a compile error, not undefined behavior. The underlying mechanism is a consteval constructor that parses the format string and checks it against a std::format_string<Args...> template.

But std::format stops there. Parse, validate, done. The compile-time information about the format string’s structure is consumed during validation and then discarded; none of it flows into the runtime behavior in a programmable way.

That’s the gap that P2996 reflection, and meta::substitute specifically, starts to close.

What meta::substitute Actually Is

P2996, the static reflection proposal authored by Herb Sutter, Barry Revzin, Daveed Vandevoorde, and others, introduces a set of consteval metafunctions under std::meta. The core primitive is the ^^ reflection operator, which produces a value of type std::meta::info representing a type, function, template, or expression.

meta::substitute takes that one step further:

consteval auto substitute(std::meta::info templ, 
                          std::span<const std::meta::info> args) 
    -> std::meta::info;

Given a reflection of a template and a list of reflections representing arguments, it returns a reflection of the resulting specialization. You can then use [: ... :] splice syntax to turn that reflection back into an actual type or value.

A simple example: constructing std::tuple<int, double, std::string> programmatically at compile time.

consteval auto make_tuple_type(std::span<const std::meta::info> element_types) {
    return std::meta::substitute(^^std::tuple, element_types);
}

// Somewhere in consteval context:
auto types = std::vector{^^int, ^^double, ^^std::string};
auto tuple_type = make_tuple_type(types);
// [:tuple_type:] is now std::tuple<int, double, std::string>

This doesn’t sound revolutionary until you ask: where does that element_types list come from? The answer, in the string interpolation context, is from parsing a format string.

String Interpolation and the Compile-Time Parse

Revzin’s string interpolation work (referenced in the original article) proposes a facility where you can write something like:

auto result = interpolate("x={x} and y={y} and z={z}", x, y, z);

At compile time, the library parses that format string, identifies three interpolation sites, maps each {identifier} to its corresponding argument, and generates the appropriate formatting code. The output at runtime is just concatenated strings with the values substituted in.

That’s already useful, but the real payoff is the meta::substitute step. Because the library knows the structure of the format string at compile time, it can construct a strongly-typed representation of the argument list. It builds the tuple type whose elements match exactly the types of the arguments in the order they appear, using meta::substitute to assemble that tuple type from the reflected argument types.

This gives you a compile-time artifact that encodes the full structure of the interpolation: how many slots there are, what types they hold, and where they appear in the string.

The Highlighting Example

The example Revzin uses to motivate why this matters is a highlighting algorithm. Given a format string and a set of arguments, you want to produce output where certain interpolated values are visually marked, like wrapping them in *...* for emphasis. The desired output looks something like:

5 and y=*10* and z=hello!

Where 10 (the value of y) is highlighted and the others are not.

This requires the formatter to, at compile time, know which argument corresponds to which position in the string, so it can apply a different formatting rule to that specific slot. A runtime format string cannot do this cleanly: you’d need to parse the string at runtime, figure out slot positions, and dispatch differently per-slot, which is both slower and harder to express correctly.

With compile-time format strings and meta::substitute, the library constructs a type that carries the per-slot metadata. Each slot can have associated policy: format normally, format with highlighting, skip entirely. The meta::substitute call assembles the final type that the formatting engine works against, and the compiler can optimize the whole thing down to essentially what you’d write by hand.

This is what Revzin means when he notes it was “not possible with the other design.” A design that accepts runtime format strings, even if it provides ergonomic syntax, cannot feed compile-time slot metadata into a type-level representation.

Why This Pattern Generalizes

The interesting thing about meta::substitute is that it’s not a format-string-specific tool. It’s a general mechanism for programmatic template instantiation, and the string interpolation case is just one application.

Consider a SQL query builder that parses a query string at compile time and derives the result-row type from the projected columns. Or a serialization library that inspects a struct’s fields via reflection and substitutes them into a generated encoder type. Or a state machine library that reads a DSL string and constructs a transition table type whose dimensions are known at compile time.

All of these follow the same pattern: parse some structured input at compile time, extract type information from it, use meta::substitute to assemble a concrete type from those pieces, then work against that type at runtime with zero overhead.

P2996 reflection is still working through the standardization process, and the feature set continues to evolve. The substitute function has been one of the more stable parts of the design because it maps cleanly onto something the compiler already does (template instantiation), just exposed as a first-class compile-time operation.

The Implementation Effort

Revzin mentions that working through the string interpolation implementation was about an hour a day during his daughter’s nap time, which is a testament to how much the reflection machinery genuinely reduces the friction of this kind of metaprogramming. Pre-P2996, implementing even a fraction of this would require stacking constexpr string processing, manual template recursion, and careful SFINAE. The new interfaces let you write what you mean.

That accessibility matters. The barrier to implementing powerful compile-time DSLs in C++ has historically been so high that most projects just didn’t bother. When the tools are easier to use, more people use them, which produces more real-world validation of the design, which produces a better standard.

The format string case is a good entry point precisely because most C++ developers have already internalized the mental model of std::format: you write a string literal, provide arguments, get output. Compile-time parsing is already part of that mental model. meta::substitute just lets the library do more with the parse results than it previously could.

The C++26 timeframe looks increasingly like the point where static reflection lands in a usable form. Work like Revzin’s string interpolation is useful both as a motivating example for the committee and as early evidence that the design works in practice. The gap between what std::format validates and what a fully reflection-aware formatter can do turns out to be large, and meta::substitute is a significant part of why.

Was this interesting?