Skip to content

Template strings in Rust

Template strings have become widespread in modern programming languages, but Rust is a notable exception. Here I want to shed light on the design space and rationale for template strings in Rust, and present a proposal.

Template strings allow interpolating values in a string. Some languages also support custom template strings for specialized use cases:

// template string in JavaScript
`Hello, ${person.name}!`
// tagged template string
sql`SELECT * FROM users WHERE id = ${handle} ORDER BY ${sortField};`
// this is equivalent to...
sql(
["SELECT * FROM users WHERE id = ", " ORDER BY ", ";"],
handle, sortField
)

String interpolation is supported in JavaScript, C#, Python, Swift, Ruby, Kotlin, Scala and Dart, among others. Java and C++ are working on proposals to add string interpolation as well.

Strictly speaking, string interpolation isn’t required, especially in a language with a powerful macro system. Rust uses format_args! and related macros for string interpolation:

println!("x is {x}"); // Display impl
println!("x is {x:?}"); // Debug impl

But this approach has several disadvantages:

  • Flexibility: Rust doesn’t allow arbitrary expressions in format_args!, only single variables. We could allow more complex expressions here, but some things (e.g. nested string literals) would be difficult to support.

  • Complexity: The format_args! macro has many configuration options, making its bespoke syntax difficult to understand and remember. For example, here are some valid formatting arguments:

    "{:#?} {:04} {:e} {:p} {:width$} {:<5} {:+5} {1}
    {:[fill]>5} {:x} {:b} {:+} {:.7} {:.prec$} {:.*}"

    These are all fairly simple, but you can also combine them (e.g. "{: >#w$.2?}"), and you need to be careful to write the options in the right order: fill, alignment, sign, alternate flag, leading zero, width, precision, formatting trait.

  • Syntax highlighting: Editors like VS Code provide syntax highlighting by special-casing format_args!, but most websites do not.

  • Extensibility: The built-in formatting options are powerful, but not extensible. For example, they don’t allow locale-aware formatting.

  • Availability: The formatting machinery is not available in no_std environments, e.g. Rust for Linux.

  • Performance: format_args! is not a zero-cost abstraction. The ufmt crate, for example, is faster and has a smaller binary size footprint.

The RFC for implicit format args explains why it only allows single identifiers in format strings:

If any expressions beyond identifiers become accepted in format strings, then the RFC author expects that users will inevitably ask “why is my particular expression not accepted?”. This could lead to feature creep, and before long perhaps the following might become valid Rust:

println!("hello { if self.foo { &self.person } else { &self.other_person } }");

This no longer seems easily readable to the RFC author.

This is the strongest argument against expressions in template strings. This sentiment is also expressed in the most upvoted comment in the IRLO thread about the topic. However, this argument is flawed: It is a classic slippery slope fallacy.

This line of reasoning does not refute the argument that an if/else in a string literal can be unreadable. But the argument has a more serious flaw: It assumes that syntax must be chosen in a way that unreadable code becomes impossible.

Let’s consider this position for a moment! Rust, like all powerful languages, already allows for code that is deeply confusing:

fn punch_card() -> impl std::fmt::Debug {
..=..=.. .. .. .. .. .. .. .. .. .. .. .. .. ..
..=.. ..=.. .. .. .. .. .. .. .. .. .. ..=.. ..
..=.. ..=.. ..=.. ..=.. .. ..=..=.. ..=..=..=..
..=..=.. .. ..=.. ..=.. ..=.. .. .. .. ..=.. ..
..=.. ..=.. ..=.. ..=.. .. ..=.. .. .. ..=.. ..
..=.. ..=.. ..=.. ..=.. .. .. ..=.. .. ..=.. ..
..=.. ..=.. .. ..=..=.. ..=..=.. .. .. ..=..=..
}
fn r#match() {
let val: () = match match match match match () {
() => ()
} {
() => ()
} {
() => ()
} {
() => ()
} {
() => ()
};
assert_eq!(val, ());
}

If we posit that unreadable code should be impossible, then the above should be rejected by the compiler. We could do this by disallowing ranges within ranges, and match constructs in a match scrutinee. But adding exceptions like these makes the grammar more and more complicated. Furthermore, no matter how many things we make illegal, there will always be potential for unreadable code.

Of course, the examples above are not real-world code, they are edge cases from the compiler’s test suite. But producing unreadable code is much simpler: You can write functions with 10 or more arguments. You can write loops within loops within loops. You can use unintelligible variable names. Etc.

The real solution is code review. In the TypeScript codebases where I have worked, with both experts and Junior devs, we have never had a problem with unreadable template strings.

Since Rust already has prefixed literals (b"", r"", c""), it’s intuitive to add another prefix, f:

// old
println!("{a} + {b} = {}", a + b);
buf.write_fmt(format_args!("{a} + {b} = {}", a + b))?;
// new
println(f"{a} + {b} = {a + b}");
buf.write_fmt(f"{a} + {b} = {a + b}")?;

This is possible since arbitrary string prefixes were reserved in the 2021 edition. Note that the println! macro can be replaced with a println() function, which accepts a single argument.

Formatting options are important, but the syntax can be greatly simplified using extension traits:

// old
println!("{foo:width$.2}");
// new
println(f"{foo.width(width).precision(2)}");

Not only is this easier to understand, it is also naturally extensible:

use textwrap::Indented;
use owo_colors::OwoColorize;
use num_format::{Locale, ToFormattedString};
println!("{text.indented(4).red()}");
println!("{number.to_formatted_string(&Locale::de)}");

The benefits of this approach are clear:

  • It is regular Rust syntax that works with IDE autocompletion, documentation tooltips, syntax highlighting, etc.
  • Rather than a symbol, it has a descriptive name that’s easy to understand
  • It is a zero-cost abstraction: What you don’t use, you don’t pay for.
  • Formatting functions don’t have to be implemented in the standard library, the can live in 3rd party crates.
  • Formatters can be easily combined.

Only {:?} and {:#?} we should keep, because these options are used very often, and concise syntactic sugar is important for debugging.

It is tempting to just desugar f"…" to format_args!("…") (which doesn’t allocate a String), but then template strings would inherit the complex formatting machinery, which we don’t want. So let’s go back to the drawing board. Here’s what we need:

  1. A way to display text to the user, formatted in various ways
  2. A way to print values and data structures for debugging

Today, these use cases are mostly handled by the Display and Debug traits. I expect that Debug will continue to be used, and {…:?} and {…:#?} will be available in template strings. However, compile-time reflection (which is a project goal for 2025) will hopefully make #[derive(Debug)] obsolete, so debug printing Just Works™.

For user-facing formatting, I propose a new trait to supersede Display:

trait Pretty {
fn fmt(&self, f: &mut core::pretty::Formatter) -> core::pretty::Result;
}

The difference to Display is that this Formatter doesn’t include any formatting options. It just provides the following methods:

  • write(impl Pretty)
  • writeln(impl Pretty)
  • write_char(char)
  • write_str(&str)
  • write_display(impl Display) for interoperability

Here is an example how Pretty could be implemented for a type representing a parser warning:

impl pretty::Pretty for ParseWarning {
fn fmt(&self, f: &mut pretty::Formatter<'_>) -> pretty::Result {
f.writeln(self.warning)?;
f.writeln(f" at {self.file()}@{self.range()}")?;
if let Some(note) = &self.note {
f.writeln(f" {"note".blue()}: {note.pretty()}")?;
}
Ok(())
}
}

Because write() and writeln() accept any type implementing Pretty, we can also give it a template string, which is more convenient than having to use the write! macro.

With a blanket Display implementation for all Pretty types, migration should be smooth. To ease migration, the old std::fmt::Formatter can also get a write_pretty method.

Making the Pretty trait interoperable with Display poses a problem: To make it work, the core::pretty::Formatter also needs to use &dyn std::fmt::Write under the hood, which is impossible while the latter is not in core. But it should be possible to move std::fmt::Write to core, since it doesn’t require allocation and the Error type is just a unit struct. But that’s the least fleshed-out part of this idea.


If you enjoy reading about language design, let me know. Discussion on Reddit.