String Interpolation with Template Strings

Document #: P3951R2 [Latest] [Status]
Date: 2026-03-15
Project: Programming Language C++
Audience: EWG
Reply-to: Barry Revzin
<>

1 Revision History

Since [P3951R1]: completely restructured the paper to put examples up front

Since [P3951R0]:

2 Introduction

The std::format approach to formatting offers many significant benefits over the prior <iostream>s approach that need not be revisited here. However, <iostream> does still have one significant advantage: ordering. It is easy to see at a glance with a long std::cout statement which pieces are to be formatted in which order. With std::format, as the amount of replacement fields increases, it becomes increasingly difficult to ensure that they are all correctly ordered.

The solution to this problem is string interpolation: the ability to put the expression to be formatted inside of the format string. This lets us preserve all of the advantages of std::format, while also regaining ordering. String interpolation is a wildly popular language feature due to the ease with which it allows users to express complex ideas. It’s not surprising that a huge number of modern languages support this to some degree or another. A non-exhaustive list includes: C#, D, Elixir, F#, Groovy, Kotlin, JavaScript, Perl, PHP, Python, Ruby, Rust, Scala, Swift, and VB.

Prior work in this space, both in C++ and in other languages, will be discussed later.

3 Design

This paper proposes that we introduce string interpolation for C++ following the same idea as Python’s template strings ([PEP-750], see also the section on prior art). In short, a template string (t-string) simply captures information to be formatted later, unlike an f-string, which is eagerly formatted. We’ll have to come up with a better name than “template string” for this, but for now I’m going to stick with it.

A template string literal will eagerly evaluate all of the expressions and produce a new object with all of the relevant pieces, such that it will be suitable for both formatting APIs (like std::format and std::print) and other APIs that have nothing to do with formatting.

Here is a simple example:

auto get_result() -> int { return 42; }

auto example() -> void {
    auto interp = t"The result is {get_result()}\n";
}

will evaluate as:

auto example() -> void {
    struct Template {
        static consteval auto fmt() -> char const* { return "The result is {}\n"; }
        static consteval auto num_interpolations() -> size_t { return 1; }
        static consteval auto string(size_t i) -> char const* {
            constexpr char const* data[] = {"The result is ", "\n"};
            return data[i];
        }
        static consteval auto interpolation(size_t i) -> std::interpolation {
            constexpr std::interpolation data[] = {{"get_result()", "{}", 0, 1}};
            return data[i];
        }

        constexpr auto exprs() const -> Template const& {
            return *this;
        }

        int _0;
    };

    auto interp = Template{get_result()};
}

where std::interpolation is the simple aggregate:

struct interpolation {
    char const* expression;
    char const* fmt;
    size_t index;
    size_t count;
};

Let’s go through all of these pieces in order. A template string will generate an instance of a unique type (like a lambda expression) by parsing the replacement fields of the string literal. The parsing logic here is very basic and does not need to understand very much about either the format specifier mini-language or C++ expressions more broadly. The interpolation type will have five public pieces of information in it:

With this structure, we can easily add additional overloads to the format library to handle template strings:

template <TemplateString S>
auto print(S&& s) -> void {
    auto& [...exprs] = s.exprs();
    std::print(s.fmt(), exprs...);
}

Note that a template string is just an object, that contains within it all the relevant information. So whether you directly invoke std::print(t"...") or first bind the result of the template string literal to an object doesn’t matter.

The rest of the paper will go through some examples first, and then work through the details, after the design will have been motivated better.

Keep in mind that since a template string object is just an object, where most of the information are static member functions, this ends up being a very embedded-friendly design too.

3.1 Examples

There are many things you can do with a template string, so let’s just run through them.

3.1.1 Basic Formatting

Here is a fun example:

auto verb() -> std::string;

auto example(std::string const& name, std::string relation) -> void {
    auto tmpl = t"Hello, my name is {name:?}. You killed my {relation}. Prepare to {verb()}.";
}

I already showed how we could print this directly:

template <TemplateString S>
auto print(S&& s) -> void {
    auto& [...exprs] = s.exprs();
    std::print(s.fmt(), exprs...);
}

All of the regular formatting facilities (std::format, std::format_to, spdlog::info, etc.) can be implemented like this. But they could also be implemented a little bit differently — by doing the type-checking internally and calling, e.g., vprint_unicode directly. An abbreviated libc++ implementation of println might look like this:

template <TemplateString S>
auto println(S&& s) -> void {
    auto& [...exprs] = s.exprs();
    [[maybe_unused]] constexpr auto check = std::format_string<decltype(exprs)...>(s.fmt());
    std::__print::__vprint_unicode(stdout, s.fmt(), std::make_format_args(exprs...), true);
}

3.1.2 Highlighting

But we could do a lot more with this object other than just print it. We could write a function to automatically highlight the interpolations green and bold, which is supported by the fmt library but not the standard. That’s straightforward, since we’re not tied into any particular library, and we have all the information we need:

template <TemplateString S>
auto highlight_print(S&& s) -> void {
    constexpr size_t N = s.num_interpolations();

    auto& [...exprs] = s.exprs();

    template for (constexpr int I : std::views::indices(N)) {
        fmt::print(s.string(I));

        constexpr auto interp = s.interpolation(I);
        constexpr auto [...J] = std::make_index_sequence<interp.count>();
        fmt::print(fmt::emphasis::bold | fg(fmt::color::green),
                   interp.fmt,
                   exprs...[interp.index + J]...);
    }

    fmt::print(s.string(N));
}

3.1.3 Structured Logging

A completely different example would be to turn it into the JSON object {"name": "Inigo Montoya", "relation": "father", "verb()": "die"} for use as structured logging:

template <TemplateString S>
auto into_json(S&& s) -> boost::json::object {
    auto& [...exprs] = s.exprs();

    boost::json::object o;
    template for (constexpr int I : std::views::indices(s.num_interpolations())) {
        constexpr auto interp = s.interpolation(I);
        o[interp.expression] = exprs...[interp.index];
    }
    return o;
}

3.1.4 printf

Or we could use entirely different formatting mechanisms. We could make this work with printf too! This isn’t a complete implementation, but should give a sense of what’s possible:

template <TemplateString S>
auto with_printf(S&& s) -> void {
    constexpr char const* fmt_string = [&]() consteval {
        std::string fmt = s.string(0);
        auto nsdms = nonstatic_data_members_of(remove_cvref(^^S), std::meta::access_context::current());

        for (size_t i = 0; i < s.num_interpolations(); ++i) {
            // in theory, this logic would be more interesting
            auto interp = s.interpolation(i);
            auto type = remove_cvref(type_of(nsdms[interp.index]));
            if (type == ^^int) {
                fmt += "%d";
            } else {
                if (interp.fmt == std::string_view("{:?}")) {
                    fmt += "\"%s\"";
                } else {
                    fmt += "%s";
                }
            }

            fmt += s.string(i + 1);
        }

        return std::define_static_string(fmt);
    }();

    auto adjust = []<class T>(T const& arg){
        if constexpr (std::same_as<T, std::string>) {
            return arg.c_str();
        } else {
            return arg;
        }
    };

    auto& [...exprs] = s.exprs();
    constexpr auto [...Is] = std::make_index_sequence<s.num_interpolations()>();
    std::printf(fmt_string, adjust(exprs...[s.interpolation(Is).index])...);
}

3.1.5 Multiple Reuse

The important thing is to expose all the relevant information to users to let them do whatever they want with it. Note that highlighted_print uses the fmt, index, and count fields of the interpolation, since it is formatting all of them, but not the expression field. Meanwhile, into_json uses only expression and index — it doesn’t need any of the format specifier logic, since it isn’t actually doing formatting. The printf example uses string to build up its own format specifier. The SQL example below likewise builds up its own formatter, and binds arguments with a different API.

And of course, all of these operations can be performed on the same object (which is particularly useful in the case of wanting to do both regular and structured logging):

auto verb() -> std::string { return "die"; }

auto main() -> int {
    std::string name = "Inigo Montoya";
    int width = 5;

    auto msg = t"Hello, my name is {name:?}. You killed my {relation}. Prepare to {verb():*^{width}}.\n";
    std::print(msg);
    highlighted_print(msg);
    std::println("{}", into_json(msg));
    with_printf(msg);
}

will print (note that highlighted_print uses the format specifiers, so the name is quoted):

Hello, my name is "Inigo Montoya". You killed my father. Prepare to *die*.
Hello, my name is "Inigo Montoya". You killed my father. Prepare to *die*.
{"name":"Inigo Montoya","relation":"father","verb()":"die"}
Hello, my name is "Inigo Montoya". You killed my father. Prepare to die.

Having both interp.index and interp.count is a little clunky, especially since interp.count will almost always be 1. But I think it’s better to put the clunkiness there and maintain the trivial formatting implementations (where you can just unpack the template string object).

You can see this example on compiler explorer.

3.1.6 Asynchronous/Background Logging

In many logging utilities, copies arguments is significantly cheaper than formatting them, so formatting itself is deferred to a background thread. In order to do that safely, we need to be able to make sure that we’re not serializing any references — which means that we need to be able to transform the members of a template string object as appropriate. This design supports that. The example is illustrating that:

auto main() -> int {
    auto logger = Logger();

    int age = 17;
    std::string name = "Bob";

    // this template string object is holding references into age and name
    // (which you can see in the debug output)
    logger.info(t"My name is {name} and I am {age} years old.");

    // which I'm now mutating (as a proxy for destruction or other race conditions)
    age = 42;
    name = "Carol";

    // but this prints fine, because we're not holding any references
    logger.flush(); // prints: My name is Bob and I am 17 years old.
}

3.1.7 SQL Statements

One of the most famous SQL injection examples is, of course, Little Bobby Tables. We can use template strings to make it easy to build up a statement properly. This example uses SQLiteCpp, but the same idea can be used for any other SQL library really. All you need to know about the library is that it works as follows (assuming we have a std::string name):

Current Library Usage
Using Template Strings
SQLite::Statement query(
    db,
    "SELECT * FROM test WHERE name = ?");
query.bind(1, name);
auto query = SQLite::makeStatement(
    db,
    t"SELECT * FROM test WHERE name = {name}");

We can provide a nice API for it like this:

template <TemplateString S>
auto makeStatement(Database& db, S&& s) -> Statement {
    constexpr char const* sanitized_fmt = [&]() consteval {
        std::string fmt = s.string(0);
        for (size_t i = 0; i < num_interpolations(); ++i) {
            // every interpolation is just turned into ?
            fmt.push_back('?');

            // ... while the string parts are preserved
            fmt.append(s.string(i + 1));
        }
        return std::define_static_string(fmt);
    }();

    auto query = Statement(db, sanitized_fmt);

    auto& [...exprs] = s.exprs();
    template for (constexpr int I : std::views::indices(s.num_interpolations())) {
        // could make different choices here: do we want to just use the value
        // or do we want to format it with its provided specifiers? presumably the
        // SQL library author would know what the right thing to do here is
        query.bind(I + 1, exprs...[s.interpolation(I).index]);
    }

    return query;
}

And now you get the same nice string formatting syntax for SQL queries as you do for strings.

3.1.8 Internationalization / Translation

Translation is a much harder example to fit in a small scope, but here is an abbreviated demonstration, that actually shows off many important aspects of translation.

First, as part of the build process itself (not a separate script or tool), the example is generating a .pot file for translators to use. This contains all the text that we need to translate, along with all of the types of the arguments. The .pot itself is also embedded in the binary, which is the first thing the program prints:

=== extracted .pot (from the binary itself) ===
#. placeholders: retries: int
msgid "Connection lost, retrying ({retries} attempts left)..."
msgstr ""

#. placeholders: name: std::string
msgid "Hello, {name}!"
msgstr ""

#. placeholders: name: std::string, post: std::string
msgid "{name} commented on {post}."
msgstr ""

#. placeholders: n: long
msgid "Removed {n} files."
msgstr ""

#. placeholders: user: std::string
msgid "Hi, {user}!"
msgstr ""

#. placeholders: price: double
msgid "Total: {price:.2f}"
msgstr ""

It then also validates the catalogue:

=== catalog validation ===
error: [de] message "Hello, {name}!": no placeholder named 'naem'

And shows that we can also properly pluralize:

for (auto loc : {"en", "fr", "pl"}) {
    i18n::set_locale(loc);
    std::print("[{}]", loc);
    for (long n : {0L, 1L, 2L, 5L, 22L}) {
        std::print("  {}", std::format(i18n::translate(t"Removed {n} files.")));
    }
    std::println("");
}

which prints:

[en]  Removed 0 files.  Removed 1 file.  Removed 2 files.  Removed 5 files.  Removed 22 files.
[fr]  0 fichier supprimé.  1 fichier supprimé.  2 fichiers supprimés.  5 fichiers supprimés.  22 fichiers supprimés.
[pl]  Usunięto 0 plików.  Usunięto 1 plik.  Usunięto 2 pliki.  Usunięto 5 plików.  Usunięto 22 pliki.

The way this works is that i18n::translate registers the string so that it can be looked up by locale, where each locale will then both define the appropriate translations and plurals. For instance, the Polish file looks like this:

# Polish catalog: the classic three-way plural, driven by the real
# gettext Plural-Forms formula below.
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2;\n"

msgid "Removed {n} files."
msgstr ""
"{n, plural,"
" 0 {Usunięto # plik.}"
" 1 {Usunięto # pliki.}"
" 2 {Usunięto # plików.}}"

What makes this example especially interesting and exciting is that eagerly-interpolating string designs simply do not work for localization. The ecosystem’s linters and language-design discussions warn against it. This is why many languages have deferred designs: C#’s FormattableString, JavaScript’s tagged templates, and Python template strings. However, each of those designs is missing some part of the source text. Two relevant points of interest here:

Deferred interpolation objects are not new, and the design in this paper is heavily inspired by them. But each carries only part of the surface and all three are runtime-only values. This is the first design to my knowledge that both produces a complete object retaining all information and even allows access to much of that information during compile time, which moves the translation tooling to “extraction and per-message validation performed by the compiler and build” rather than some sort of separate tooling or runtime interception.

3.2 Language Design Specifics

Now that we’ve gone through some cool examples, let’s go through all the design specifics to work through all of the details.

3.2.1 Lexing

A template string is conceptually an alternating sequence of string literals and interpolations. The string literal parts are just normal string literals — we look ahead until we find a non-escaped { to start the next replacement field ("{{" in a format string is used to print the single character '{'). A replacement-field comes in two forms:

replacement-field:
  { expr }
  { expr : format-spec }

For example, a template string like t"The price of {id:x} is {price}." needs to be lexed into these five pieces:

String Literal "The price of "
Interpolation "{:x}" with expression id
String Literal " is "
Interpolation "{}" with expression price
String Literal "."

The first and last piece are always (possibly-empty) string literals — for N interpolations there will be N+1 strings. Even for the template string t"{expr}" which entirely consists of an expression, there will be two empty string pieces.

However, C++ expressions can be arbitrarily complicated. Notably, they can also include : or }. both of which are significant in formatting and indicate the end of the expression. So how do we know when we’re done with the expr part here?

One approach would be to simply limit the kinds of expressions that can appear in template strings. Rust, for instance, only supports identifiers. That obviously makes parsing quite easy, but it also is very limiting. On the other extreme, supporting all expressions can easily lead to indecipherable code. I think on balance, supporting only identifiers is far too restrictive. But once you start adding what other kinds of expressions to allow (surely, at least class member access), it quickly becomes too difficult to keep track of what is allowed (indexing? function calls? splices?) and ironically makes both the implementation more difficult (to enforce what is and isn’t allowed) and harder to understand for the user (to know which expressions are and aren’t allowed).

I think it’s best to simply allow anything in the expression (as Python does) and trust the user to refactor their expressions to be as legible as they desire. This allows us to take a very simple approach: we simply lex expr as a balanced token sequence (just counting {}s, ()s, and []s), so that colons and braces inside of any of the bracket kinds are treated as part of an expression. But the first : or } encountered when we’re at a brace depth of zero means we’re done with expr.

Here are some examples of template string formatting calls and how they would be evaluated. The first column will show a template string that consists entirely of an expression. The second column will show how the format string for that expression will be lexed and the third column will show the expression.

template string
lexed format string
lexed expression
t"{x}" "{}" x
t"{[]{ return 42; }()}" "{}" []{ return 42; }()
t"{co_await f(x) + g(y) / z}" "{}" co_await f(x) + g(y) / z
t"{a::b}" "{}" a::b (see below)
t"{cond ? a : b}" "{: b}" cond ? a
t"{(cond ? a : b)}" "{}" (cond ? a : b)

Note that it is possible to lex an invalid expression due to finding a : first, as in the penultimate line. The lexing won’t know about this though.

3.2.2 Looking for :

The first problem we run into, as evidenced in the above table, is deciding what it means to find the separator between the expression and the format-spec . How do we find the :? A this point, we will be lexing an expression — and there are multiple ways in which a colon can appear here:

One consideration is what format specifiers exist today. Notably, "{::x}" is a valid format specifier (e.g. used to format a range of integers in hex) and "{:>5}" is a valid format specifier (e.g. used to right-align a field with width 5). Indeed, any sequence of characters can be a valid format specifier. I think it is important that any existing format specifier be usable with string interpolation, otherwise users would find it surprising when something they try to do randomly doesn’t work.

However, if we try to simply search for the character : rather than the token :, we run into a different problem. The string "{a::b}" would lex as the expression a (with format specifier "::b"), which would suggest needing to parenthesize as "{(a::b)}" to get the expression a::b. That would be pretty surprising, given that any top-level use of scoping wouldn’t work as intending. Up to and including "{::global}", which would parse as no expression at all.

I think we need to both support the ability to use ::x as a format specifier and also use top-level scoping conveniently. In order to do that, we need a slightly more involved rule, but one which will just do the right thing most of the time. Let’s walk through a table first before discussing the rule:

template string
lexed format string
lexed expression
t"{::global}" "{}" ::global
t"{v::x}" "{}" v::x
t"{(v)::x}" "{::x}" (v)
t"{decltype(a)::b}" "{}" decltype(a)::b
t"{cond ? a : b}" "{: b}" cond ? a
t"{(cond ? a : b)}" "{}" (cond ? a : b)

The most convenient differentiator between wanting to use the expression v::x and to use the expression v with the format specifier ::x is to parenthesize v (as in the difference between the 2nd and 3rd lines above). ):: can only appear in an expression in a few rare contexts, like decltype(x)::y.

Hence the rule proposed is: top-level : starts the format specifier, except if it’s ::, except after a non-decltype ). So if the specifier needs to start with :, parenthesize the expression. This allows us to support all format specifiers while also ensuring that users don’t have to add surprising and unexpected parentheses:

int global = 3;
namespace v { int x = 2; }

auto example() -> void {
    std::vector<int> v = {10, 20, 30};
    std::println(t"{(v)::x}");   // [a, 14, 1e]
    std::println(t"{v::x}");     // 2
    std::println(t"{::global}"); // 3
}

Lastly, there’s the question of UCNs. I’ll deal with that in its own section.

3.2.3 Looking for { and }

As with the question of :, there are multiple different ways to spell { and }, because of course there are:

Character
Digraph
UCN
{ <% \u007b
} %> \u007d

Which of these spellings can start a reflection-field and which of these spellings can end one? Let’s start with other languages. Given a variable v with value 42, what happens in…

Language
Expression
Result
Python f"\u007bv\u007d" "{v}"
Rust format!("\u{007b}v\u{007d}"); "42"
Ruby "#\u007bv\u007d" "#{v}"

In Rust, interpolation only accepts identifiers. But in Python and Ruby, any expression can be interpolated, the same as I’m proposing here. This makes the question of looking for } more complicated, since what do you do when you see the \? In an expression context, UCNs can appear — but not to name characters in the basic character set. Like }.

To me, the { and } that start and end a replacement-field is more akin to the braces around a compound-statement (in which a UCN is not allowed) than braces within a string. Which I suppose is an argument for supporting the digraph spellings too. But why? What is the point of supporting this? The goal is to be legible.

To that end, this paper proposes that only literally the characters { and } start and end a replacement-field. Not the digraphs <% and %> and not the UCNs \u007b and \u007d. It is certainly implementable to support a different option. But there isn’t a benefit to doing it, and other languages don’t either.

3.2.4 Handling Macro Expansion

While lexing expressions, macro expansion occurs too (although at a later phase, see below). This is both what users expect and is important to support, otherwise we’re not actually meeting the claim of supporting all expressions. There are even standard utilities that are defined as macros, like errno. The one thing to note is that looking for the terminating : or } of an expression will not consider such a character from macros. Otherwise, the macros wouldn’t actually be usable properly and also the format string itself would become illegible.

For instance:

#define MIN(a, b) (a) < (b) ? (a) : (b)

auto example() -> void {
    int x = 3, y = 4;
    std::println(t"{(x < y ? x : y)}"); // 3, parentheses required
    std::println(t"{MIN(x, y)}");       // 3, no additional parentheses required.
}

3.2.5 Preprocessing Equivalence and Custom Names

Consider

#define ANSWER 42
auto s = t"{ANSWER}";

It’s an essential part of tooling that compiling and preprocessing then compiling are equivalent. That whichever of these two paths we take, we end up with the same object file:

Mermaid diagram

So what does this preprocess into? If it preprocess into this:

auto s = t"{ANSWER}";

Then we end up with an ill-formed program, with unknown identifier ANSWER. If however it preprocesses into this:

auto s = t"{42}";

Then we’ve actually changed the behavior of the program: now the expression string is different. decltype(s)::interpolation(0).expression is "ANSWER" in the original program, but has now become "42".

Instead, I’m proposing that this program preprocess into this:

auto s = t"{42;"ANSWER"}";

That is, and expression can end with a ; and then provide a string literal for its new name. This preprocessed output and the original program now have the same behavior.

Moreover, it’s actually useful to be able to provide a custom name anyway, since the C++ expression might not have the exact shape you want for some use-cases, like structured logging and translation. The translation example, for instance, has this line:

std::println(i18n::translate(t"Hi, {u.display_name();"user"}!"));

Because the string we need to look up is "Hi, {user}". But our user expression is u.display_name(), this lets us do both. This is also useful if you want to use certain spellings in your translation to indicate kinds:

std::println(i18n::translate(t"{amount;"123"} Objects of Type: {names[0];"XYZ"}"));

3.2.6 Lexing Trailing Equals

One nice debugging feature that Python’s f-strings (and template strings) have is the equals suffix:

>>> f"{x=}, {y=}, {z=}"
"x=5, y=7, z='hello world'"

Concretely, an expression that ends with an = (surrounded by any amount of whitespace) has that suffix appended to the previous string literal piece. Occasionally, there are requests to support std::print(x, y, z) to just concatenate those three elements — but that’s not as useful as it initially seems since you quickly forget what variables you’re printing in what order. The ability to support this on the other hand is very useful for debugging:

std::println(t"{x=}, {y=}, {z=:?}");

It is also quite easy to implement, since it’s just a matter of checking if the last lexed token of the expression was an =. And, if so, dropping that from the expression (since no valid C++ expression ends with =) and instead adding the stringified expression to the previous string part.

Concretely: t"Hello {name=}" behaves exactly equivalently to t"Hello name={name}".

Note that this isn’t quite what Python does — as you might notice from the Python example. In Python, it behaves like t"Hello name={name!r}, which is basically calling repr(name) instead of str(name). It would be really nice if we actually had a real answer for debug formatting — but neither std::format nor fmt::format really have one. There is a ? specifier which is used to help ensure that range formatting properly works ([P2286R8]), but it’s not valid across all types, and there’s no special handling for it. So we can’t really make t"{name=}" evaluate as t"name={name:?}", since that would only work for a small set of types. Instead, this paper proposes not to add any format specifier here.

3.2.7 Lexing Nested Expressions

Consider the template string:

t"{name:>{width}}"

There are two expressions here: name and width. Or rather, we know name is an expression, but how do we know that width is? In the format model, the format specifiers can be anything. There really are no rules — as long as the type’s formatter can handle it. Having nested braces in a format specifier commonly refers to another argument, but it need not actually mean that.

In my CppCon 2022 talk The Surprising Complexity of Formatting Ranges, I work through an example of how one might add underlying specifiers to std::pair and std::tuple, where:

int main() {
    auto elems = std::tuple(10, 20, 30);
    fmt::print("{}\n", elems);                  // (10, 20, 30)
    fmt::print("{:{x}{#x}{-^4}}\n", elems);     // (a, 0x14, -30-)
    fmt::print("{:{x}{}{x}}\n", elems);         // (a, 20, 1e)
}

There, {x} doesn’t refer to the expression x, it was just chosen as notational convenience. So how can we get this to work?

fmt::print(t"{elems:{x}{}{x}}\n");

The short answer is: we cannot. We need to make sense of the template string literal separate from type information, and even if we had type information, it’s not like we have a way for the formatter API to signal when it’s expecting an expression. We just have to make a choice up front for what to do here. I think we have three options:

  1. We could explicitly opt in to a nested expression. That is, maybe something like t"{elems:{x}{}${x}}" signals that the first {x} is just a string but the second {x} is actually the expression x. So in the above example, we simply wouldn’t use the $.
  2. We could explicitly opt out of being a nested expression with added escaping. Which in this case would be t"{elems:{{x}}{{}}{{x}}}".
  3. We could say that in a format specifier, encountering a { always begins an expression that ends at : or } (same as top-level) and that there is neither opt-in nor opt-out. Meaning that the approach to format specifiers that I demonstrated in that talk wouldn’t work, and would instead have to be t"{elems:{:x}{}{:x}}".

I think the third option here is the best. It is at most a minor burden on users as I doubt this approach is in widespread use, and allows for a design that is as simple as possible.

Getting back to our original example:

t"{name:>{width}}"

This would lex as:

String Literal ""
Interpolation "{:>{}}" with two expressions: name and width
String Literal ""

And this works because we recognize {:x} as not being a nested expression:

fmt::print(t"as template: {elems:{:x}{}{:x}}\n"); // as template: (a, 20, 1e)

If we simply stored the expressions and the format string, that would be straightforward: we just have two data members. But we can do better than that. But before we get into the interpolation information, I’ll talk about the data members.

3.2.8 Other Lexical Rules of Expressions

Other lexical rules for expressions:

3.2.9 Concatenating Consecutive String Literals

Consecutive string literals are concatenated during preprocessing. The same should hold true for template string literals — which can be concatenated with each other and also with regular string literals, in any order. In the table below, imagine we are initializing a variable s to the token sequence shown in the first column and examining the resulting s.fmt() and the expressions being lexed:

Tokens
s.fmt()
expressions
"Hello, " "World" n/a n/a
"Hello, " t"{name}" "Hello, {}" 1: name
t"{greeting}, " t"{name}" "{}, {}" 2: greeting, name
t"{greeting}, " "World" "{}, World" 1: greeting
t"{greeting}, " "{}" "{}, {}" 1: greeting

Note the last line. We’re concatenating a template string literal and a regular string literal — that simply concatenates the contents of the 2nd string literal onto the last string piece of the 1st — there is no implicit escaping of the braces, so the resulting format string would be incomplete — it has 2 replacement fields but only one expression.

We could consider implicitly escaping the braces for regular string literals that are concatenated to template string literals. I don’t know if that’s a good idea though.

3.2.10 Data Members

For any expression, E (including nested expressions — so there may be more expressions than interpolations), a non-static data member will be generated (and then initialized from E) having type decltype((E)). This ensures that we get the right type, but also that we’re not copying anything unnecessarily. For instance:

auto verb() -> std::string;

auto example(std::string const& name, std::string relation) -> void {
    auto tmpl = t"Hello, my name is {name:?}. You killed my {relation}. Prepare to {verb()}.";
}

The object tmpl will have three members:

These are all public, non-static data members. If we want to copy all of the members (e.g. because we want to serialize them), we can easily do so. It’s just that there is no need for the template string itself to do so directly. Note that the call to verb() happens immediately and the result is stored in tmpl. We are not lazily holding onto the expression verb().

This does open up the opportunity for dangling if you write something like this:

auto oops(int value) { return t"{value}"; }

That template string object will have an int& member, refer to the parameter that will be destroyed when we return from the function. I don’t think this is a huge use-case of template strings, but it’s something to keep in mind. The decltype((E)) logic is essential for ensuring no overhead for template strings in the expected use-cases. We could consider something like a leading = to capture by value instead of by reference, but users can already write t"{auto(value)}" there too.

3.2.11 Why exprs()?

In the design introduction, I showed that a template string will produce an object that has a non-static member function exprs() which just returns a reference to itself. We then studiously call s.exprs() everywhere to get the actual expression pieces. Why exprs() instead of just relying on the template string object itself to be destructurable?

The motivation here is to allow easier support for cases where it’s not the object itself that is destructurable. The two examples in this paper are:

In both cases, the object we’re producing doesn’t have a member for every expression. This is doable with reflection, but it’s just more work without really any benefit. The exprs() design allows that to happen.

3.2.12 Interpolation Information

A template string will have a static consteval member function which returns objects of type std::interpolation, which is a simple aggregate with four members:

struct interpolation {
    char const* expression;
    char const* fmt;
    size_t index;
    size_t count;
};

This means that using string interpolation will require including a header that defines this type, similar to how you need <initializer_list> or <compare> when using initializer lists or three-way comparisons. This probably shouldn’t go into <format> directly since it has broader use than that, maybe a new header named <interpolation>?

expression is a string literal that is the stringified version of the expression (before macro expansion). fmt is the full format specifier with the expression removed, that you would need in order to format this specific expression. Then, we need both index and count in order to support nested replacement expressions, as we just went through. That gives us both the first non-static data member and the amount of non-static data members this interpolation is associated with.

The template string literal t"{name=:>{width}}" would generate the object

struct Template {
    static consteval auto fmt() -> char const* { return "name={:>{}}"; }
    static consteval auto num_interpolations() -> size_t { return 1; }
    static consteval auto string(size_t n) -> char const* {
        constexpr char const* data[] = {"name=", ""};
        return data[n];
    }
    static consteval auto interpolation(size_t n) -> std::interpolation {
        constexpr std::interpolation data[] = {{
            .expression = "name",  // note that "name" is preserved
            .fmt = "{:>{}}",       // but "width" is not
            .index = 0,
            .count = 2
        }};
        return data[n];
    }

    std::string const& _0;
    int& _1;
};

Why do we go through the trouble of providing string(n) and interpolation(n)? The examples above show why.

3.2.13 The TemplateString Concept

In these examples, I’ve been using this TemplateString concept to identify a template string object. The question is, what does that concept look like? This one I’m not sure about yet. It can’t be a built-in, since users might need to create one of these objects. Consider a logger:

log::info(t"Got a trade for {symbol}: {side} {qty} @ {price}");

The expressions here are all cheap to copy, but formatting is expensive — so I might want to serialize all of the data into a background thread to do my formatting there. I don’t want to just copy the template string object, since it might have references. So we can create a version that only has value members, with all other operations preserved:

template <TemplateString S, class F>
auto map(S s, F f) {
    auto& [...pieces] = s.exprs();

    struct R {
        static consteval auto fmt()                   { return S::fmt(); }
        static consteval auto string(size_t n)        { return S::string(n); }
        static consteval auto num_interpolations()    { return S::num_interpolations(); }
        static consteval auto interpolation(size_t n) { return S::interpolation(n); }

        std::tuple<std::invoke_result_t<F, decltype(pieces)>...> values;
        auto exprs() const -> auto const& { return values; }
    };

    return R{.values{std::invoke(f, FWD(pieces))...}};
}

Which allows the implementation of all of the logging functions to map their provided template string object to decay or otherwise transform every member into something that won’t dangle.

I’d want to make sure this R here is also considered a template string for all of these purposes. So probably the best approach here (which is what I’ve implemented) is structural conformance: std::template_string is a concept that checks for the presence of fmt, string, num_interpolations, interpolation, and exprs with suitable shapes.

You can see a heavily simplified example of what a background-formatted asynchronous logger would look like here.

3.2.14 Runtime Format Strings

Let’s consider the translation example in slightly more detail.

In that example, we have a variable template that builds up a single pot entry:

template <std::template_string S, class... Args>
[[gnu::used, gnu::section("i18n_messages")]]
inline constexpr auto pot_entry = build_pot_record<S, Args...>();

That is built entirely at compile time, using the shape of template strings that have already been described. This is great since there’s no other tooling needed in order to produce all this information. Just compile your program.

But at runtime, we have to do translation. That’s the whole point. This paper throughout assumes that a template string object, t"...", is produced by the implementation, and thus has its format string as a constant (proposed specifically as a static consteval function returning a char const*). Of course, if we’re doing runtime translation, we’re not going to end up with a constant… anything. If we want to support this syntax:

std::print(i18n::translate(t"Removed {n} files."));

Then we need the ability to have some sort of runtime template string. Note that this differs from the gettext guidance which tells you to use std::vformat directly instead of std::format. I’d like to aim higher, we can certainly do better.

Now, the implementation I showed for std::print earlier was this:

template <TemplateString S>
auto println(S&& s) -> void {
    auto& [...exprs] = s.exprs();
    std::println(s.fmt(), exprs...);
}

This… doesn’t actually require fmt() to be a constant. It either has to be constant or be the result of a call to std::dynamic_format. One option is do something like this:

template <std::template_string S>
auto translate(S ts) {
    // this figures out the right thing to do, and is a complicated function
    // but its complexity isn't that interesting for our purposes
    std::string translated = lookup_translation(ts);

    struct Translated {
        std::string translated_fmt;
        S s;

        auto fmt() const { return std::dynamic_format(translated_fmt); }
        auto exprs() const -> S const& { return s; }
    };

    return Translated{
        .translated_fmt = std::move(translated),
        .s = std::move(ts),
    };
}

And thus have two layers of concept:

Dynamic Template String Static Template String
  • s.fmt() returns either char const* or std::dynamic_format
  • s.exprs() returns some destructurable object
  • S::fmt() must be static and return a constant char const*
  • S::string(n) must be static and return a constant char const*
  • S::num_interpolations() must be static and return a constant size_t
  • S::interpolation(n) must be static and return a constant interpolation
  • s.exprs() returns some destructurable object

Note that the static template string concept would subsume the dynamic one. The formatting use-cases (like std::format, std::print, and spdlog::info) only require a dynamic template string. Some of the more complex ones (like SQL and printf) require a static template string.

However, once we recognize the need for a dynamic template string concept, it’s basically always going to look exactly the same. So rather than push that down to users, we can just provide it in the library, tentatively under the name std::rebind_format:

template <std::template_string S>
auto translate(S ts) {
    // same as above
    std::string translated = lookup_translation(ts);

    // ... now just rebind
    return std::rebind_format(std::move(translated), std::move(ts));
}

No need for users creating their own thin type wrappers that all have the same exact shape.

We specify:

template <template_string S, class Pattern>
struct rebound-format; // exposition-only

template <template_string S, class Pattern>
    requires convertible_to<Pattern const&, string_view>
rebound-format<S, Pattern> rebind_format(Pattern pattern, S s);

And then constrain all the formatting functions to take either a template_string or a specialization of rebound-format.

3.2.15 Support for User-Defined Literals

Consider the expression:

auto msg = t"The result is {get_result()}\n"s;

Does this make sense to support? It has obvious meaning — evaluate as the call operator"" s(t"The result is {get_result()}\n"). And that literal operator can be defined to call std::format. The rule in 12.6 User-defined literals [over.literal] would have to be extended to support this signature:

template <class T>
auto operator""  ud-suffix (T ) -> R;

That actually gives people the terse string construction that they want, without an additional language feature that needs to be customized.

The rules of which user-defined literal operator is even looked for is based on the kind of the literal. So t"x={x}"s will never even attempt to look for the existing operator"" s and, similarly, adding a new one to the standard library will never affect code that currently does "hello"s, since the literal operator template would not even be a candidate.

This extension is very narrow, and is effectively specific to template strings. But there is high demand for a terse facility to just produce a std::string, so we should provide the ability to do so.

3.3 Standard Library Design

For the standard library, we need several pieces:

I think we should add a new header <interpolation> that puts the interpolation utilities that don’t strictly require std::format altogether, namely:

// <interpolation>
namespace std {
    struct interpolation { /* ... */  };

    template <class S>
    concept template_string = /* ... */;
}

std::rebind_format and the new operator"" s UDL will both go into <format>. The other overloads will go in the same place as the already-existing ones.

The last piece that’s potentially worth considering is adding a string constructor. Conceptually, these two declarations are equivalent:

auto msg1 = t"The result is {get_result()}\n"s;
std::string msg2 = t"The result is {get_result()}\n";

So is it worth touching std::string? I don’t think it actually is. In direct code, std::string and std::format are the same length, so you don’t gain anything from having the constructor. The only potential advantage would be calling a function that takes a std::string like f(t"x={x}") (or similar initialization examples). On the one hand, it doesn’t seem completely wrong to add it (since having s is conceptually similar) and it’s easy enough to implement. On the other, once we add s, it hardly seems necessary.

3.4 Implementation Experience

I implemented this in Clang, on top of the p2996 reflection branch. Code can be found in my fork in the compiler-explorer/barry branch: here. I’m sure there are better ways to do some of what I did. The implementation includes the design laid out in this section, including UDL support, and also the library support — the concept, new overloads of formatting function templates, and a new s literal operator for string.

It can also be used on compiler explorer.

The only difference between the implementation and what’s being proposed is that I didn’t add a new <interpolation> header. All the interpolation stuff is defined in <format> (or rather, in implementation-defined, smaller headers that are exposed via <format>).

3.5 More Formal Lexing Specification

In Clang, preprocessing happens during lexing — and so the way I implemented it was that a template string lexes as a new kind of token — really a meta-token that itself contains a bunch of other tokens. That’s fine as far as Clang goes (or maybe not, there might be a more preferred approach), but the standard defines the phases of translation (5.2 Phases of translation [lex.phases]) in a strict order. Specifically:

The wording needs to fit this specification. Which we can do by defining a new set of preprocessing tokens. For example, the template string

t"My name is {name():>{width}}.\n"

can lex into the tokens (indentation for clarity):

template-string-begin
  string-literal "My name is "

  template-string-interpolation-begin
    string-literal "{:>{}}"
    ,
    string-literal "name()"

    template-string-expression-begin
      identifier "name"
      (
      )
    template-string-expression-end

    template-string-expression-begin
      identifier "width"
    template-string-expression-end
  template-string-interpolation-end

  string-literal ".\n"
template-string-end

That is, we have a bunch of meta-tokens that don’t lead to any output and are just there to guide parsing. A template string literal lexes into this alternating sequence of string-literals and interpolations. An interpolation is bounded by template-string-interpolation-begin and template-string-interpolation-end, starts with two string-literals for the format string and expression (with a separator token just to avoid string concatenation from kicking in — I used , above but the actual separator doesn’t matter), and then continues with at least one expression. An expression is bounded by template-string-expression-begin and template-string-expression-end.

To handle trailing =, we could go one of two ways. Consider just t"Got {x = }.". That could be either:

Pre-emptively append
Delayed concat
template-string-begin
  string-literal "Got x = "

  template-string-interpolation-begin
    string-literal "{}"
    ,
    string-literal "x"
    template-string-expression-begin
      identifier "x"
    template-string-expression-end
  template-string-interpolation-end
  string-literal "."
template-string-end
template-string-begin
  string-literal "Got " // the initial string
  string-literal "x = " // expression with trailing =
  template-string-interpolation-begin
    string-literal "{}"
    ,
    string-literal "x"
    template-string-expression-begin
      identifier "x"
    template-string-expression-end
  template-string-interpolation-end
  string-literal "."
template-string-end

It works either way. With delayed concatenation, we rely on phase 5 to concatenate the string literals before parsing anyway.

We could conceivably also just introduce a single token and use braces for everything else. Though probably better to ues something other than braces to make it easier to diagnose poorly formatted expressions? Whatever people prefer:

Several tokens for clarity
Just one token
template-string-begin

  string-literal "My name is "

  template-string-interpolation-begin
    string-literal "{:>{}}"
    ,
    string-literal "name()"

    template-string-expression-begin
      identifier "name"
      (
      )
    template-string-expression-end

    template-string-expression-begin
      identifier "width"
    template-string-expression-end
  template-string-interpolation-end

  string-literal ".\n"
template-string-end
template-string
{
  string-literal "My name is "

  {
    string-literal "{:>{}}"
    ,
    string-literal "name()"

    {
      identifier "name"
      (
      )
    }

    {
      identifier "width"
    }
  }

  string-literal ".\n"
}

4 Prior Art and Alternate Approaches

This proposal is definitely not the only way to do string interpolation in C++. I’ve already discussed two previous proposals in this space and why I think what I’m proposing is a better design. But it’s worth talking about other approaches as well.

4.1 Prior Work in C++

There have been two prior WG21 papers pursuing string interpolation as a C++ language feature: [P1819R0] (Interpolated Literals) and [P3412R3] (String Interpolation). The two proposals are quite different, so let’s consider an example to work through the details:

auto get_result() -> int { return 42; }

auto example() -> void {
    auto interp = f"The result is {get_result()}\n";     // #1
    std::print(interp);                                  // #2
    std::print(f"The result is {get_result()}\n");       // #3
}

In P1819, the interp is an object that is roughly equivalent to:

auto interp = [&](auto&& f) -> decltype(auto) {
    return f("The result is ", get_result(), "\n");
};

So line #1 does approximately nothing. The call to get_result() does not happen yet. Instead, the library would provide new overloads of std::print and friends so that in line #2, the library would invoke interp with the appropriate function to do the printing. The call to get_result() happens at that point. Line #3 does the same things as lines #1 and #2, just together.

In P3412, the behavior is very different. interp is already a std::string, which is evaluated as:

auto interp = std::format("The result is {}\n", get_result());

This makes the call in line #2 ill-formed, since std::print cannot accept a std::string. However, the call in line #3 is valid — by way of a change to overload resolution that recognizes this case as special and instead evaluates the call directly as:

std::print("The result is {}\n", get_result());

In short, P1819 gives us an object (that doesn’t evaluate any of the expressions) while P3412 gives us either a std::string or an argument list, depending on context.

Of the two, I think P1819 is significantly better. We get a simple object that can allow for a wide variety of potential functionality. It has two big problems though. The first is that it evaluates lazily and stores its data opaquely — which leads to more surprising behavior, the potential for dangling references, and arbitrarily limited usage. The other is its breakup into pieces doesn’t actually play very well with std::format — where we would want there to be a format string and we don’t have one. The original motivation for the lambda approach was ease of use — get all the expressions in one convenient format. But the language has evolved since 2019. We have both reflection and packs in structured bindings now, so we don’t need the lambda approach anymore.

On the other hand, P3412 is actually not one but two different language features — and it’s worth taking some time to evaluate this. This is more explicit in [P3412R1]:

Expression
Evaluates As
std::print(x"The result is {get_result()}");
std::print("The result is {}", get_result());
auto s = f"The result is {get_result()}");
auto s = std::format(x"The result is {get_result()}");
auto s = std::format("The result is {}", get_result());

The x-literal did string interpolation — it evaluated as an expression-list. That’s the workhorse that provides the value of the feature. In contrast, the f-literal was simply syntax sugar for a call to std::format with the appropriate x-literal. It’s a language feature for simply calling std::format. Not precisely std::format — since not everybody uses std::format so instead this was introduced as a language customization mechanism. But we’re really just abbreviating a function call.

In [P3412R3], this becomes significantly more complicated because both of those features (the string interpolation part, and the just-calling-std::format part) converge to the same spelling as an f-literal. This is I think inherently suspect because the same expression now means different things in different contexts. Because the spelling is the same, there needs to be a way for the language to differentiate which one the user meant — and that mechanism is overload resolution coupled very strongly to the current implementation strategy of formatting. The call

std::print(f"The result is {get_result()}");

Works by relying on the first parameter to std::print having a consteval constructor. But what if someday we get constexpr function parameters and it turns out to be better to implement basic_format_string<char, Args...> as taking a constexpr string_view instead of it being a consteval constructor? What if we someday get a different/better macro system such that std::print("x={}", x) evaluates not as a call to a function template but rather directly as the expression std::vprint(validate_fmt_string<int>("x={}"), std::make_format_args(x))?

It’s not infeasible that some future language change gives us a better way to solve this problem. But with the P3412R3 design, we wouldn’t be able to adopt those changes to the formatting functions because they would break string interpolation (unless we come up with a new, more complicated interpolation design, which would now have to recognize multiple implementation strategies).

All this complexity buys us is the ability to create a std::string in a single character. However, I don’t think that’s even a good goal for C++ — we shouldn’t hide an operation as costly as string formatting in a single character — and spelling std::format is not itself a huge burden. Now, without that aspect of the design, the P3412 approach of having string interpolation emit an expression-list is a lot simpler — I will do a comparison of the two approaches later in this paper.

Instead, this paper proposes an idea much closer to the P1819 model.

4.2 Prior Art in Other Languages

Python 3.6 introduced literal string interpolation (f"...") in [PEP-498], which was later extended in Python 3.14 by template strings (t"...") in [PEP-750]. The former directly produces a string, while the latter gives a template string — an object with enough information in it to be formatted later.

Rust’s format_args! is similar to Python’s template string — it gives you a completely opaque object (unlike Python’s which is completely specified). Both languages gives you a facility to take an interpolated string and produce an object for future work (similar to P1819).

JavaScript also has template literals, which support tagging. A tagged template literal is quite similar to what [P3412R3] proposes:

Code
Evaluates as
 myTag`That ${person} is a ${age}.`
myTag(["That ", " is a ", "."], person, age)

This would be similar to P3412’s having myTag(f"That {person} is a {age}") evaluate the transformed call myTag("That {} is a {}", person, age). Here, the literal is not an object.

C#’s interpolated strings can produce a string directly, But if they are bound to a FormattableString, you can get the string parts and objects separately for future work. Similar to Python template strings and Rust’s facility, except type erased. C# also has a more complicated facility on top of string interpolation called an InterpolatedStringHandler, which allows for more efficient and even conditional (lazy) formatting.

Swift’s string interpolation, similar to C#, can also either directly produce a string or go through a separate, builder path: the protocol ExpressibleByStringInterpolation. That allows the expression:

Code
Evaluates as
let value: MyType = "Hello, \(name)!"
var builder = MyType.StringInterpolation(
    literalCapacity: 8, interpolationCount: 1)
builder.appendLiteral("Hello, ")
builder.appendInterpolation(name)
builder.appendLiteral("!")
let value = MyType(stringInterpolation: builder)

This paper’s design is along the lines of Python’s template strings, Rust’s format_args!, and C#’s FormattableString idea. Just presented in a package that is more, well, C++.

4.3 Object vs Expression-List

As I mentioned earlier, P3412 is really two language features: a string interpolation feature whose intermediate representation is an expression-list, and a feature which just calls std::format on that expression-list. In contrast, this paper is a string interpolation feature whose intermediate representation is an object. How do those two intermediate representations compare?

When it comes to formatting specifically — when the sink algorithm is std::print or std::format_to or spdlog::info or anything like that — the object approach is pure overhead. Being able to write spdlog::info(f"x={x}") and have that evaluate exactly as spdlog::info("x={}", x) means that no library change is necessary whatsoever in order for libraries to “adopt” string interpolation. You can’t beat zero work. With this paper, there would have to be library opt-in. Those opt-ins are going to be very simple — mostly two liners as you can see from the print implementation earlier — but they still have to exist.

So is it worth the added complexity of the object model to justify the added cost of the interpolation opt-in? We have to talk about the added functionality.

Because the object approach preserves all the information in the original format string, you can do things like structured logging (as in the JSON example from earlier). The expression-list approach simply doesn’t have the “names” of the expressions anymore, so they’re not available for further use.

But almost anything else you might want to do with the interpolated string that isn’t precisely formatting is much easier when you have an object. It’s actually still surprising to me that some of these things are even possible, but let’s walk through some examples. For logging, I might want to also include the file/line number as part of the message. I have this information available at compile-time along with the format string, so it’d be nice to concatenate those together. That’s possible:

Expression-list (P3412)
Object (this paper)
template <class... Args>
struct fmt_string_sloc {
    char const* fmt;

    template <class S>
        requires std::convertible_to<S, std::string_view>
    consteval fmt_string_sloc(S s,
                              std::source_location sloc =
                                std::source_location::current()) {
        // have to do type-checking here
        [[maybe_unused]] auto _ = std::format_string<Args...>(s);

        this->fmt = std::define_static_string(
          std::format("[{}:{}] {}",
                      sloc.file_name(),
                      sloc.line(),
                      std::string_view(s)));
    }
};

template <class... Args>
auto print_sloc1(fmt_string_sloc<std::type_identity_t<Args>...> fmt,
                Args&&... args) -> void {
    std::print(std::runtime_format(fmt.fmt), args...);
}
template <std::template_string S>
auto print_sloc2(S&& s) -> void {
    constexpr auto fmt = []{
        using T = std::remove_cvref_t<S>;

        auto sloc = source_location_of(^^T);
        return std::define_static_string(
          std::format("[{}:{}] {}",
                      sloc.file_name(),
                      sloc.line(),
                      T::fmt()));
    }();

    // type-checking happens out here as usual
    // since fmt is a constant
    auto& [...exprs] = s.exprs();
    std::print(fmt, exprs...);
}

We cannot provide the source location as a parameter to print_sloc1, because it’s a variadic function template. And even if we could, it couldn’t be a constant. However, we can be clever and provide it as a defaulted parameter to to the consteval constructor of the non-deduced fmt_string_sloc. With the object, we can just directly get the source location of the string interpolation type.

This difference in complexity goes up really fast once you start doing more interesting things. Consider the highlight_print example from earlier. This is actually implementable in the expression-list model, but not easily:

Expression-list (P3412)
Object (this paper)
struct Interpolation { char const* fmt; int index; int count; };

struct Information {
    size_t num_interpolations;
    char const* const* strings;
    Interpolation const* interpolations;
};

template <Information Info, class... Args>
auto highlight_print_impl(fmt::text_style style, Args&&... exprs) -> void {
    constexpr size_t N = Info.num_interpolations;

    template for (constexpr int I : std::views::indices(N)) {
        fmt::print(Info.strings[I]);

        constexpr auto interp = Info.interpolations[I];
        constexpr auto [...J] = std::make_index_sequence<interp.count>();
        fmt::print(style,
                   interp.fmt,
                   exprs...[interp.index + J]...);
    }

    fmt::print(Info.strings[N]);
}

template <class T>
constexpr auto parse_next_impl(fmt::parse_context<char>& ctx) -> void {
    fmt::formatter<T> f;
    auto cur = ctx.begin();
    if (*cur == ':') {
        ++cur;
    }
    ctx.advance_to(cur);
    (void)ctx.next_arg_id();
    cur = f.parse(ctx);
    if (cur != ctx.end()) {
        ++cur;
    }
    ctx.advance_to(cur);
}

template <class... Ts>
consteval auto into_interpolation_info(std::string_view sv)
  -> std::meta::info
{
    fmt::detail::type types[] = {
      fmt::detail::mapped_type_constant<Ts, char>::value...};
    auto ctx = fmt::detail::compile_parse_context<char>(
      sv, sizeof...(Ts), types, 0);

    constexpr auto arg_id = nonstatic_data_members_of(
        ^^fmt::parse_context<char>,
        std::meta::access_context::unchecked())[1];

    std::vector<char const*> strings;
    std::vector<Interpolation> interpolations;
    std::vector<std::meta::info> args = {^^Ts...};

    while (true) {
        // next string
        auto next = std::find(ctx.begin(), ctx.end(), '{');
        strings.push_back(
          std::define_static_string(std::string_view(ctx.begin(), next)));
        if (next == ctx.end()) {
            break;
        }

        // next interpolation
        ctx.advance_to(next + 1);
        int const index = ctx.[:arg_id:];
        auto parse_fn = substitute(^^parse_next_impl, {args[index]});
        extract<auto(*)(fmt::parse_context<char>&)->void>(parse_fn)(ctx);
        int const count = ctx.[:arg_id:] - index;

        interpolations.push_back({
            .fmt = std::define_static_string(
              std::string_view(next, ctx.begin())),
            .index=index,
            .count=count
        });
    }

    auto info = Information{
        .num_interpolations = interpolations.size(),
        .strings = std::define_static_array(strings).data(),
        .interpolations = std::define_static_array(interpolations).data(),
    };

    return std::meta::reflect_constant(info);
}

template <class... Ts>
struct highlight_format_string {
    auto (*impl)(fmt::text_style, Ts&&...) -> void;

    template <class S> requires std::convertible_to<S, std::string_view>
    consteval highlight_format_string(S str) {
        [[maybe_unused]] auto check = fmt::format_string<Ts...>(str);

        impl = extract<auto(*)(fmt::text_style, Ts&&...)->void>(
            substitute(^^highlight_print_impl, {
                into_interpolation_info<std::remove_cvref_t<Ts>...>(
                  std::string_view(str)),
                ^^Ts...
            }));
    }
};

template <class... Ts>
auto highlight_print(fmt::text_style style,
                     std::type_identity_t<highlight_format_string<Ts...>> fmt,
                     Ts&&... args) -> void {
    fmt.impl(style, (Ts&&)args...);
}
template <TemplateString S>
auto highlight_print(fmt::text_style style, S&& s) -> void {
    constexpr size_t N = s.num_interpolations();

    auto& [...exprs] = s.exprs();

    template for (constexpr int I : std::views::indices(N)) {
        fmt::print(s.string(I));

        constexpr auto interp = s.interpolation(I);
        constexpr auto [...J] = std::make_index_sequence<interp.count>();
        fmt::print(style,
                   interp.fmt,
                   exprs...[interp.index + J]...);
    }

    fmt::print(s.string(N));
}

The implementation on the left reuses {fmt} implementation details, to avoid having to re-implement parsing on my own — that’s just to save some effort, it’s not strictly necessary. The fact that it’s implementable at all is kind of incredible (thanks to Reflection), but the difference in complexity here is pretty vast. But this is because we have to basically re-implement interpolation in user-space and then come up with a clever way have that still work during constant evaluation time. Once we actually do all that work, we can produce the same representation, so the actual interesting part (highlight_print_impl on the left) looks the same as it does on the right. But you have to do all that work first.

So the comparison boils down to this:

It depends on how interested we are in all of those other use-cases. I can’t promise that none of them will ever be useful, so it seems like a good forward-looking trade-off to me.

4.4 f-strings

Given that this paper proposes UDL support, there doesn’t seem to be any reason to add an additional language feature to directly produce a std::string:

       auto a = std::format("My name is {} and my age next year is {}", name, age+1); // status quo
       auto b = std::format(t"My name is {name} and my age next year is {age+1}");    // proposed
       auto c = f"My name is {name} and my age next year is {age+1}";                 // not proposed
       auto d = t"My name is {name} and my age next year is {age+1}"s;                // proposed
       auto e = std::string(t"My name is {name} and my age next year is {age+1}");    // not proposed
std::string f = t"My name is {name} and my age next year is {age+1}";                 // not proposed

Sure, using d requires a using namespace std::literals; or a more specific one somewhere, but if that level of terseness is desired, it’s worth it. Certainly doesn’t seem worth it for me to have a dedicated language feature for this.

4.5 Reduced Representation

What should this template string literal evaluate to?

t"New connection on {ip:#x}:{port}"

This paper proposes the one on the left, but we could just do the one on the right:

Proposed
Simpler
struct S {
  static consteval auto fmt() -> char const*;
  static consteval auto num_interpolations() -> size_t;
  static consteval auto string(size_t n) -> char const*;
  static consteval auto interpolation(size_t n) -> interpolation;

  u32 _0;
  u16 _1;
};
struct S {
  static consteval auto fmt() -> char const*;




  u32 _0;
  u16 _1;
};

But the main motivation (and likely the most common use-case) is some version of formatting, for which all we need is S::fmt(). We wouldn’t need S::interpolation(n) or S::string(n). Should we still generate the extra functions?

I would argue that we should. The implementation has to do all the work to get those pieces anyway (with the exception of the index and count members for each interpolation, which really isn’t much work), so it’s not like we’re saving much in the way of computation by stripping the interface. The simpler interface is only simple in that it reduces the available functionality. Doesn’t seem like a good idea.

4.6 Redundant Information

Continuing with the previous example, what if instead of removing S::string and S::interpolation, we instead removed S::fmt?

Proposed
Minimal
struct S {
  static consteval auto fmt() -> char const*;
  static consteval auto num_interpolations() -> size_t;
  static consteval auto string(size_t n) -> char const*;
  static consteval auto interpolation(size_t n) -> interpolation;

  u32 _0;
  u16 _1;
};
struct S {

  static consteval auto num_interpolations() -> size_t;
  static consteval auto string(size_t n) -> char const*;
  static consteval auto interpolation(size_t n) -> interpolation;

  u32 _0;
  u16 _1;
};

Note that S::fmt() is exactly the result of concatenating S::string(0), S::interpolation(0).fmt, S::string(1), S::interpolation(1).fmt, and S::string(2). This is true by construction for all template strings, and is precisely how it is implemented as well. Given that S::string(n) and S::interpolation(n) will both exist (as being more fundamental), do we need to also provide S::fmt() — which can simply be derived from both arrays?

One advantage of removing S::fmt() is to avoid having that string spill into the binary even if unused, if the implementation simply fails to detect its lack of use. However, I think we should. While formatting will not be the only usage of these objects, it is both the main motivating and primary one, so it will both be more convenient for users and more efficient if the compiler simply does that little bit of extra work to produce the full format string as well.

And regardless, the opposite problem would still exist anyway — if only S::fmt() were used, there is the potential that the string literals in S::string(n) and S::interpolation(n) spill into the binary unnecessary as well.

4.7 Static Data Members or Static Member Functions

The proposal right now has 4 static member functions. But a simple alternative would be to instead provide three, with strings() and interpolations() returning std::spans instead of having a string(n) and interpolation(n) and num_interpolations():

struct S {
    static consteval auto fmt() -> char const*;
    static consteval auto strings() -> std::span<char const* const>;
    static consteval auto interpolations() -> std::span<std::interpolation const>;
};

The advantage of approach is that it allows directly looping over the interpolations via:

template for (constexpr auto interp : s.interpolations()) {
    // ...
}

Which instead some of the examples work around via:

template for (constexpr auto I : std::views::indices(s.num_interpolations())) {
    constexpr auto interp = s.interpolation(I);
}

The disadvantage is that it brings in the std::span dependency. But if we’re declaring std::interpolation anyway, that’s probably not that big a deal.

A very different shape might instead be to have static data members instead of static functions, where:

struct S {
    static constexpr char const* fmt = /* ... */;
    static constexpr char const* strings[] = /* ... */;
    static constexpr std::interpolation interpolations[] = /* ... */;
};

This would avoid the span dependency and avoid a bunch of parentheses that you would have to write, as compared to the other function version. However, it has two problems. First, a template string can have no interpolations, and we still don’t have zero-sized arrays in C++. We ran into this problem with specifying std::meta::reflect_constant_array, and it’s very annoying that it doesn’t just work (even as gcc and clang happily support them with expected semantics with no warnings). Neither the version where interpolations() returns a std::span nor the version where we have interpolation(n) which returns the nth interpolation have this problem.

The second problem is that these types may have to be local types in some contexts, and local types cannot have static data members in C++. I do not know why local types have this restriction, given that local types are allowed to have static member functions and those member functions are allowed to have static local variables. But the restriction does currently exist.

4.8 Wait for Reflection

The evergreen question with proposals like this is to wonder if we should wait for reflection. There even is a proposal, [P3294R2] (Code Injection with Token Sequences), that has walks through how a future macro could solve this problem. Concretely, we would need:

Given those pieces, the implementation of something like Rust’s format_args! is basically the same as how you would implement it in a compiler. And the benefit of being able to do this in a library is pretty clear: it is much easier to experiment with different functionality. Plus, this would just be a very small taste of what code injections could do.

So should we wait? It seems incredibly unlikely that we will land something as expansive as token sequence injection in C++29 (if ever?), and a dedicated language feature for template string objects is pretty small and self-contained.

5 References

[P1819R0] Vittorio Romeo. 2019-07-20. Interpolated Literals.
https://wg21.link/p1819r0
[P2286R8] Barry Revzin. 2022-05-16. Formatting Ranges.
https://wg21.link/p2286r8
[P3294R2] Barry Revzin, Andrei Alexandrescu, Daveed Vandevoorde. 2024-10-15. Code Injection with Token Sequences.
https://wg21.link/p3294r2
[P3412R1] Bengt Gustafsson, Victor Zverovich. 2025-01-12. String interpolation.
https://wg21.link/p3412r1
[P3412R3] Bengt Gustafsson, Victor Zverovich. 2025-12-14. String Interpolation.
https://wg21.link/p3412r3
[P3951R0] Barry Revzin. 2026-01-10. String Interpolation Objects.
https://wg21.link/p3951r0
[P3951R1] Barry Revzin. 2026-04-16. String Interpolation Objects.
https://wg21.link/p3951r1
[PEP-498] Eric V. Smith. 2015-08-01. Literal String Interpolation.
https://peps.python.org/pep-0498/
[PEP-750] Jim Baker, Guido van Rossum, Paul Everitt, Koudai Aono, Lysandros Nikolaou, and Dave Peck. 2024-07-08. Template Strings.
https://peps.python.org/pep-0750/