Fancy strings in Scala 3
Let’s put some of the new Scala 3 features to work! While we try to escape using String
s 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 String
s (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, inline
s 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.
Have fun exploring Scala 3! :)