Contents

Fancy strings in Scala 3

Fancy strings in Scala 3 webp image

Let’s put some of the new Scala 3 features to work! While we try to escape using Strings as much as possible, we still end up manipulating them in our codebases daily. However, quite often, these are not arbitrary strings but ones with some special properties. In these cases, Scala’s compiler might be able to offer some help.

Our goal will be to create dedicated types for non-empty and lowercase strings. We’ll use opaque types and inlines, which are new features of the Scala 3 compiler.

Non-empty strings

First, let’s create a zero-cost abstraction that will represent non-empty strings. For that, we’ll define an opaque type NonEmptyString:

opaque type NonEmptyString = String

At runtime, values of type NonEmptyString will be simply Strings (hence, there’s no additional cost to the abstraction that we are introducing). At compile-time, however, these two types are treated by the compiler as completely unrelated, except for the scope in which this type alias is defined. Here, if this is a top-level definition, the scope will be the entire file, but if we created the type alias in an object, the scope in which it’s known that a NonEmptyString is in fact a String would be that object.

A type alias is a good start, but we’ll also need some way to “lift” values from a String into our new type. First, given an arbitrary value at runtime, we can write a function that returns an optional NonEmptyString. Note that this definition needs to be placed next to the opaque type alias as the compiler needs to know that these types are indeed equal:

opaque type NonEmptyString = String

object NonEmptyString:
  def apply(s: String): Option[NonEmptyString] = 
    if s.isEmpty then None else Some(s)

It’s worth noting that here we do have some additional runtime cost - allocating the option. However, we can do better with constants. We can check at compile time if they are indeed empty or not! For this, we’ll use an inline method that is guaranteed to be evaluated by the compiler as the compilation happens. In the inline’s method definition, we’ll use an inline if which can be used to verify whether a constant expression is true at compile time:

inline def from(inline s: String): NonEmptyString =
  requireConst(s)
  inline if s == "" then error("got an empty string") else s

We’re also using scala.compiletime.requireConst to get a nice error message if the value passed as a parameter is not a constant (but e.g. a value reference). If the string is empty, we’re using scala.compiletime.error to report a custom error message.

Finally, we need a way to upcast a NonEmptyString into a String. This can be done using an implicit conversion. In order to avoid an additional runtime method call, we define the conversion as an inline method as well (evaluated at compile-time). This conversion will be added automatically by the compiler, given that we import it into scope.

As Julien noted, a better way to achieve the above is to add a type bound to the definition of the opaque type: opaque type NonEmptyString <: String = String. That way, we don't need the implicit "upcasting" conversion.

Here’s the entire NonEmptyString abstraction that we have created:

import scala.compiletime.{error, requireConst}

opaque type NonEmptyString = String

object NonEmptyString:
  def apply(s: String): Option[NonEmptyString] = 
    if s.isEmpty then None else Some(s)

  inline def from(inline s: String): NonEmptyString =
    requireConst(s)
    inline if s == "" then error("got an empty string") else s

  given Conversion[NonEmptyString, String] with
    inline def apply(nes: NonEmptyString): String = nes  

Time for some tests! We need to put them in a different file or a different scope so that the opaque type is really opaque:

object Usage:
  // compilation fails: 
  // Found:    ("abc" : String)
  // Required: com.softwaremill.test.NonEmptyString
  val x0: NonEmptyString = "abc"

  // compilation succeeds
  val x1: NonEmptyString = NonEmptyString.from("abc")

  // compilation fails:
  // got an empty string
  val x2: NonEmptyString = NonEmptyString.from("")

  // compilation fails:
  // expected a constant value but found: Usage.z
  val z = "x" * 10
  val x3: NonEmptyString = NonEmptyString.from(z)

  // implicit conversion at work
  def test(param: String) = s"Got: $param"
  println(test(x1))

What’s important in the above design is that we’re creating no runtime overhead - all NonEmptyString values at runtime are the same String objects from which they are created; it’s just at compile-time that these types are distinct.

Lowercase strings

Let’s look at a slightly more complex example - creating a type that represents lowercase strings. We start the same, with an opaque type, implicit conversion to upcast back to String, and a method to lift a String into our LowerCased type. This time, the lifting works slightly differently, as we simply lowercase the parameter:

opaque type LowerCased = String

object LowerCased:
  def apply(s: String): LowerCased = s.toLowerCase(Locale.US)

  given Conversion[LowerCased, String] with
    inline def apply(lc: LowerCased): String = lc

What about typing known constants as LowerCased? Here, inlines are not powerful enough: in the inline if, we can only check constant conditions. To verify that a constant string is already lower case, we’d have to check that LowerCased(s) == s at compile time. Luckily, unlike inlines, using macros we can run arbitrary code. We won’t go in-depth into quoting & splicing (see links to articles dedicated to macros in the next section), instead, we’ll focus on the macro logic that is required here.

We’ll need two methods. A user-facing one that “lifts” the parameter it receives into the macro-land (so that we can inspect & manipulate it at compile-time) and “splices” the result of our manipulations back so that it is compiled normally. This method needs to be inline to be evaluated at compile-time and its body will call the macro implementation. The quoting and splicing is done using ’{} and ${}, respectively.

Additionally, we’ll need to define the macro implementation method, which operates on abstract syntax trees (ASTs) - represented in code as values of type Expr - corresponding to the chunks of code that are passed as macro parameters:

object LowerCased:
  inline def from(inline s: String): LowerCased = ${ fromImpl('s) }

  import scala.quoted.*
  private def fromImpl(s: Expr[String])(using Quotes): Expr[LowerCased] = 
    ???

In the implementation, we’ll inspect the abstract syntax tree corresponding to the parameter s. If it is a constant string, we check if the string is already lower case. Otherwise, we report an error:

object LowerCased:
  private def fromImpl(s: Expr[String])(using Quotes): Expr[LowerCased] =
    import quotes.reflect.*

    s.asTerm match
      case Inlined(_, _, Literal(StringConstant(str))) =>
        if LowerCased(str) == str then s
        else report.errorAndAbort(
          s"got a string which is not all lower case: $str")
      case _ => report.errorAndAbort(
        s"got a value which is not a constant string: ${s.show}")

Note that in case of success, the result of the method is s - the expression that is passed as a parameter. Since the macro is defined next to the opaque type alias, the compiler knows that LowerCased == String.

Let’s test our solution:

// compilation succeeds
val p1: LowerCased = LowerCased("Abc")

// compilation succeeds
LowerCased.from("abc")

// compilation fails:
// got a string which is not all lower case: Abc
LowerCased.from("Abc")

// compilation fails:
// got a value which is not a constant string: Usage.z
val z = "x" * 10
​​LowerCased.from(z)

As a result, we’ve created two “subtypes” of String, with methods to downcast (from and apply) and upcast (implicit conversions). Where possible, we’ve tried to check as much as possible during compile-time - which is one of the overarching goals when using statically typed languages.

Going further

If you’d like to learn more about macros in Scala, take a look at our tutorial and tips & tricks articles. Magda Stożek explored opaque types more in-depth in her talk on the subject.

Scala 3 Tech Report

To get a better understanding of how developers feel about the major revision in the Scala language, download the full version of the Scala Tech Report here.
scala-report-social

Have fun exploring Scala 3! :)

Blog Comments powered by Disqus.