Scala 3: How to create your own String interpolator

This is an excerpt from the Scala Cookbook, 2nd Edition. This is Recipe 2.11, Creating Your Own String Interpolator.

Scala 3 Problem

You want to create your own string interpolator, like the s, f, and raw interpolators that come with Scala.

Solution

To create your own string interpolator, you need to know that when a programmer writes code like foo"a b c", that code is transformed into a foo method call on the StringContext class. Specifically, when you write this code:

val a = "a"
foo"a = $a"

it’s translated into this:

StringContext("a = ", "").foo(a)

Therefore, to create a custom string interpolator, you need to create foo as a Scala 3 extension method on the StringContext class. There are a few additional details you need to know, and I’ll show those in an example.

Suppose that you want to create a string interpolator named caps that capitalizes every word in a string, like this:

caps"john c doe"   // "John C Doe"

val b = "b"
caps"a $b c"       // "A B C"

To create caps, define it as an extension method on StringContext. Because you’re creating a string interpolator, you know that your method needs to return a String, so you begin writing the solution like this:

extension(sc: StringContext)
   def caps(?): String = ???

Because a pre-interpolated string can contain multiple expressions of any type, caps needs to be defined to take a varags parameter of type Any, so you can further write this:

extension(sc: StringContext)
   def caps(args: Any*): String = ???

To define the body of caps, the next thing to know is that the original string comes to you in the form of two different variables:

  • sc, which is an instance of StringContext, and provides it data in an iterator

  • args.iterator, which is an instance of Iterator[Any]

This code shows one way to use those iterators to rebuild a String with each word capitalized:

extension(sc: StringContext)
   def caps(args: Any*): String =
      // [1] create variables for the iterators. note that for an
      // input string "a b c", `strings` will be "a b c" at this
      // point.
      val strings: Iterator[String] = sc.parts.iterator
      val expressions: Iterator[Any] = args.iterator

      // [2] populate a StringBuilder from the values in the iterators
      val sb = StringBuilder(strings.next.trim)
      while strings.hasNext do
         sb.append(expressions.next.toString)
         sb.append(strings.next)

      // [3] convert the StringBuilder back to a String,
      // then apply an algorithm to capitalize each word in
      // the string
      sb.toString
        .split(" ")
        .map(_.trim)
        .map(_.capitalize)
        .mkString(" ")
   end caps
end extension

Here’s a brief description of that code:

  1. First, variables are created for the two iterators. The strings variable contains all the string literals in the input string, and expressions contains values to represent all of the expressions in the input string, such as a $a variable.

  2. Next, I populate a StringBuilder by looping over the two iterators in the while loop. This starts to put the string back together, including all of the string literals and expressions.

  3. Finally, the StringBuilder is converted back into a String, and then a series of transformation functions are called to capitalize each word in the string.

There are other ways to implement the body of that method, but I use this approach to be clear about the steps involved, specifically that when an interpolator like caps"a $b c ${d*e}" is created, you need to rebuild the string from the two iterators.

Discussion

To understand the solution it helps to understand how string interpolation works, i.e., how the Scala code you type in your IDE is converted into other Scala code. With string interpolation, the consumer of your method writes code like this:

id"text0${expr1}text1 ... ${exprN}textN"

In this code:

  • id is the name of your string interpolation method, which is caps in my case

  • The textN pieces are string constants in the input (pre-interpolated) string

  • The exprN pieces are the expressions in the input string that are written with the $expr or ${expr} syntax

When you compile the id code, the compiler translates it into code that looks like this:

StringContext("text0", "text1", ..., "textN").id(expr1, ..., exprN)

As shown, the constant parts of the string — the string literals — are extracted and passed as parameters to the StringContext constructor. The id method of the StringContext instance — caps, in my example — is passed any expressions that are included in the initial string.

As a concrete example of how this works, assume that you have an interpolator named yo and this code:

val b = "b"
val d = "d"
yo"a $b c $d"

In the first step of the compilation phase the last line is converted into this:

val listOfFruits = StringContext("a ", " c ", "").yo(b, d)

Now the yo method needs to be written like the caps method shown in the Solution, handling these two iterators:

args.iterators contains:   "a ", " c ", ""   // String type
exprs.iterators contains:  b, d              // Any type

More Scala interpolators

For more details, my Github project for this book shows several examples of interpolators, including my Q interpolator, which converts this multiline string input:

val fruits = Q"""
    apples
    bananas
    cherries
"""

into this resulting list:

List("apples", "bananas", "cherries")
... this post is sponsored by my books ...

#1 New Release!

FP Best Seller

See Also