On the road to Scala 3

Smur89
SwissBorg Engineering
5 min readMay 26, 2023

Like many Scala developers, at SwissBorg we’re excited about migrating to Scala 3. Although we’re not in a position to fully migrate to Scala 3, mostly due to 3rd party libraries not yet being available for Scala 3 and partly because we’re not prepared for the inevitable “braces or no braces?” debate.

We started to look at how we might make this transition easier for ourselves when the time comes to migrate; reducing the uncertainty around runtime errors and performance, as most of our code will be already adapted.

There’s nothing groundbreaking to be discussed here, and the official Scala 3 Migration Guide is a great resource for this. However, without rewriting the migration guide, we wanted to share our approach and experiences nonetheless.

Scala 2.13

A prerequisite is to have already updated to Scala 2.13.x; the official standard library for Scala 3. From here it is rather easy to port the code to align more with Scala 3, assuming the following additional prerequisites are met:

  • It must not depend on a macro library that has not yet been ported to Scala 3.
  • It must not use a compiler plugin that has no equivalent in Scala 3.
  • It must not depend on scala-reflect.

Here’s our first taste of the compatibility improvements we get with Scala 3; we don’t need to migrate everything all at once. It is both backwards and forwards compatible and 2.13 <-> 3 cross-compilation actually works quite well on JVM with the help of the -Ytasty-reader option. This means we can already start to mix Scala 3 dependencies with our existing 2.13.x dependencies.

The exception here is our macro-based libraries, which need to be rewritten. For 3rd party macro-based libraries, we can temporarily put the code generated by them into a separate module and depend on that. It’s possible to create a Scala 3 sandwich of sorts

module A in Scala 3, module B in Scala 2.13 — depends on A, module C in Scala 3 — depends on B
Scala 3 sandwich

However, there are already efforts to provide alternatives to some popular macro based libraries. For example, one we use is scala-newtype, which could be easily replaced by Monix’s Newtypes which uses no macros, and no magic based on implicits.

Scala 3 dialect for Scala 2

The -XSource:3 compiler option is provided in 2.13.x to encourage early migration. It enables some Scala 3 syntax and behaviour such as:

  • Most deprecated syntax generates an error.
  • Infix operators can start a line in the middle of a multiline expression.
  • Implicit search and overload resolution follow Scala 3 handling of contravariance when checking specificity.
  • Ability to use & instead of within type ascription
  • Ability to use import foo.* and import foo.Bar as Bazsyntax
  • Fix for smart constructors in case classes
    case class Foo private (bar: Bar)still generated apply and copy but now it correctly hides them

Kind projector underscore placeholders

Scala 3.3 dictates the use of the _ symbol for all anonymous type parameters and aligns with Java syntax on the wildcard symbol, which is now ?. This means in Scala 3 syntax, the following expressions would be rewritten

  • EitherT[F, String, *] as EitherT[F, String, _]
  • ApplicativeError[*[_], Throwable] as ApplicativeError[_[_], Throwable]
  • List[_] as List[?]

In order to make this change with a dependency on kind-projector we need to enable the new syntax explicitly using the compiler flag -P:kind-projector:underscore-placeholders .

Rewriting

There are a few options when it comes to handling the syntax updates, including the scala3-migrate plugin. However, as we already use ScalaFmt we could instead rely on that to do the heavy lifting for us.

ScalaFmt provides a dedicated Scala213Source3 dialect for this which allows it to format some of the new syntax backported from Scala 3. Combined with the additional rewrite rules provided for Scala 3, we can have most of the rewrites taken care of by simply running sbt scalafmtAll Test/scalafmtAll

Here’s how your configuration file might look if you follow a similar approach

version = 3.7.3

runner.dialect = Scala213Source3

rewrite.scala3.convertToNewSyntax = true
rewrite.scala3.removeOptionalBraces = false
runner.dialectOverride.allowUnderscoreAsTypePlaceholder = true

Gotcha’s

So, where did we trip up along the way?

Implicit Resolution

Due to an issue with ScalaJS, the default implicit resolution when using the -XSource:3 has changed and needs to be explicitly enabled using the -Yscala3-implicit-resolution. This will keep the previous behaviour of implicit resolutions from scala 2.13

Macros

Macro based libraries need to be rewritten for Scala 3. In many services we make use of such libraries, which prevents us from moving forward with a full upgrade to Scala 3. For example, we use Chimney in many services for our data transformations, which relies on Scala 2 macros. In the case of Chimney, this is something which is actively being worked on.

Manual rewrites

Sometimes, we had to manually update some syntax in order to make our code bases compile.

With the changes for Kind Projector, ScalaFmt by default does not rewrite it correctly. Worse, when we manually updated to the correct style, ScalaFmt would know better and change it back! Eventually we found this Pull Request which solves the issue by introducing an override in the configuration. After adding

runner.dialectOverride.allowUnderscoreAsTypePlaceholder = true 

to our config, ScalaFmt relented.

A minor annoyance if you decide to instead opt to go with utilising the -Compile / scalafixOnCompile := true method of rewriting is that it seems to not remove unused wildcard imports.

ADTs

In the case of ADTs, we noticed that leaf nodes of the ADT are inferred to be the root type. This caused some confusing compilation issues for us.

sealed trait Foo

object Foo {
case class Bar() extends Foo
}

val foo = Foo.Bar() // inferred type = Foo

ScalaPb generated code

If you have a module which combines in-house code with generated code, as we had, it is recommended to split the two into separate modules. In this way, the module containing your own code can depend on the module with the generated code.

For us, this was the case with ScalaPb, and we took the opportunity to clean up and split our module as described above. This module for the generated code does not apply the -XSource:3compiler option and is consumed as a dependency from the other modules.

Tooling

When we initially performed the migration, we noticed that IDEs had some trouble correctly displaying the code highlights, even though the build was successful. Since then, support has improved a lot and this seems to be less of an issue. At the time, we took the approach of leveraging the fileOverride option and setting rewrite.scala3.convertToNewSyntax to false if it was particularly bothersome.

Next Steps

We are already working on migrating our core internal libraries to Scala 3, with some of our CI helper scripts utilising Scala 3 already. Many of these libraries are not dependent on incompatible libraries like newtype and Chimney, which will allow us to already begin our migration. This is something we’re actively working on, and we’ll begin our journey in Scala 3 with Scala 2 code style before discussing which syntax changes we want to embrace.

--

--