Jamie Thompson, Scala Center
Forward Compatibility for the Scala 3 Transition
Scala 3 is coming, with a release candidate slated for the end of 2020. With that knowledge comes the inevitable question: should I migrate, and what is the potential cost?
For maintainers of projects, the migration process may become easier with the recent release of Scala 2.13.4, which comes with a new preview feature: reading and compiling against Scala 3 dependencies.
Overview
With a guided tutorial, we will show you how Scala 2.13.4 makes the following scenarios easier:
- You have an application consisting of many subprojects that may depend on each other, built with Scala 2.13, and would like to transition each one independently to Scala 3.0
- You have an application, built with Scala 2.13, and want to use some new features of a library that has migrated to Scala 3.0
The tutorial will proceed as follows:
- We will take a small multi-module project of two sub-projects, a
shared
module, containing simple data structures, and anapp
module that depends onshared
. - We will in turn migrate each sub-project to Scala 3, and show that it does not
matter in which order you migrate the projects, as
app
will continue to build and run. - To finish, we will add a library dependency on the Scala 3 version of ScalaCheck,
and use it from Scala 2 to validate data structures in the
shared
module.
A reader applying the steps in the tutorial to their own project should note that not
all features of Scala 3 are forward compatibile with Scala 2, such as inline
methods.
Consequently, we recommend that the user limits their usage of Scala 3 exclusive
features when migrating incrementally. More information is provided in the
forward compatibility section.
In addition, we provide a troubleshooting section for the reader, which aims to suggest steps to take when applying this guide to their own projects and a problem occurs.
Status of the Tasty Reader
As a quick aside, If you would like to know how it is possible to mix dependencies between the two compilers, watch the talk Taste the Difference with Scala 3.
To summarise: Scala 3 stores meta information about the code it compiles in TASTy files, and Scala 2.13 can read these files to learn, for example, which terms, types and implicits are defined in a given dependency, and what code needs to be generated to use them correctly. The part of the compiler that manages this is known as the Tasty Reader.
Currently, reading Scala 3 dependencies from Scala 2 requires the compiler
flag -Ytasty-reader
. The Tasty Reader feature is currently released as a
preview for users to evaluate and we invite them to
give feedback
and report any bugs.
As Scala 3 reaches release, we hope to enable the Tasty Reader by default.
Scenarios
The overview section describes two scenarios that we will guide you through in this blog:
- Migrating multi-module projects incrementally from Scala 2 to Scala 3
- Taking advantage of new features in a library that is published for Scala 3.
Let’s look at each scenario in turn:
1. Migrate a Multi-Module Project in Any Order
If you want to migrate a multi module project to Scala 3, it does not matter in which order you migrate the modules, they will be able to depend on each other regardless of whether they are compiled with Scala 2.13 or Scala 3.
To illustrate this scenario, we will start with an empty directory and
build an sbt project with Scala 2.13 that has two modules, shared
,
which has some common domain model data structures, and app
,
which uses those data structures.
For this project, we will pick sbt 1.4.3, this allows you to mix projects of different Scala versions with very little extra effort (thanks to Eugene Yokota).
To begin, our project looks like the following:
// project/build.properties
sbt.version=1.4.3
// build.sbt
ThisBuild / scalaVersion := "2.13.4"
lazy val shared = project
lazy val app = project
.dependsOn(shared)
// shared/src/main/scala/example/Cat.scala
package example
sealed trait Cat extends Product with Serializable
object Cat {
case object Lion extends Cat
case object Tiger extends Cat
case object Cheetah extends Cat
}
// app/src/main/scala/example/Main.scala
package example
object Main extends App {
println(Cat.Tiger)
}
We can run this application with sbt app/run
which will output the following:
[info] Compiling 1 Scala source to blog/shared/target/scala-2.13/classes ...
[info] Compiling 1 Scala source to blog/app/target/scala-2.13/classes ...
[info] running example.Main
Tiger
At this point we can try something exciting, compile either subproject with Scala 3 and see if it works.
First, add the dotty plugin (this helps with managing and inspecting the
scalaVersion
setting with Scala 3)
// project/plugins.sbt
addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.4.5")
Then we can change the scalaVersion
of app
:
To recap, app
is now compiled by Scala 3 and depends on shared
,
which is compiled by Scala 2.13.
If we do sbt app/run
we should see the app
project recompile
and it will run as before.
Now lets try the other way around, in this case we also have to enable
reading Scala 3 dependencies in Scala 2 with the flag -Ytasty-reader
:
Here we have the opposite, app
is compiled by Scala 2.13 and depends
on shared
, which is compiled by Scala 3. We can see this more clearly using
the command sbt 'show app/dependencyTree'
, which outputs the following:
app:app_2.13:0.1.0-SNAPSHOT [S]
+-shared:shared_3.0.0-M1:0.1.0-SNAPSHOT
+-org.scala-lang:scala3-library_3.0.0-M1:3.0.0-M1 [S]
If we then try sbt app/run
both shared
and app
subprojects will recompile
and it should work as it always has. If in your own project there are issues
in changing the Scala versions, check out the
troubleshooting section.
To summarise this part, we have shown that it is possible to migrate subprojects to Scala 3 from Scala 2.13 in any order, gradually, and continue to build and run them if they mix versions.
2. Using a Scala 3 Library Dependency
In the section above, we have seen how it is possible for a subproject compiled with Scala 2.13 to depend on a subproject compiled with Scala 3. The same relationship extends to library dependencies.
To demonstrate using a third party library dependency, we will create a test suite for our
example.Cat
data structure, using ScalaCheck. Here we add it as a library dependency:
Notice that in the above snippet, we are using the Scala 3 version of ScalaCheck. To force a specific Scala version, we take a standard module id and change it as so:
By replacing %%
with %
, we can then manually specify the binary version,
leading us to add _3.0.0-M1
to the name of the module.
As a preliminary, now that Cat
is compiled with Scala 3, we will change the
definition of Cat
to an enumeration, giving us convenient ways to reflect
over its members:
Let’s now make our test suite. As a demonstration we will check two properties:
- given two
example.Cat
with the same label, they should refer to the same object. - given two
example.Cat
that refer to the same object, they should have the same label.
Here is the source code:
// app/src/test/scala/example/CatSpecification.scala
package example
import org.scalacheck.{Properties, Gen}
import org.scalacheck.Prop.forAll
object CatSpecification extends Properties("Cat") {
val allCats = Cat.values
val genCat: Gen[Cat] =
for (x <- Gen.choose(0, 100)) yield allCats(x % allCats.length)
property("`sameName -> identical`") = forAll(genCat, genCat) { (a: Cat, b: Cat) =>
a.productPrefix != b.productPrefix || a.eq(b)
}
property("`identical -> sameName`") = forAll(genCat, genCat) { (a: Cat, b: Cat) =>
a.ne(b) || a.productPrefix == b.productPrefix
}
}
If we then use the command sbt app/test
we should see the following output:
[info] + Cat.`identical -> sameName`: OK, passed 100 tests.
[info] + Cat.`sameName -> identical`: OK, passed 100 tests.
If we then run again sbt 'show app/dependencyTree'
we see the following:
app:app_2.13:0.1.0-SNAPSHOT [S]
+-org.scalacheck:scalacheck_3.0.0-M1:1.15.0
| +-org.scala-lang:scala3-library_3.0.0-M1:3.0.0-M1 [S]
| +-org.scala-sbt:test-interface:1.0
|
+-shared:shared_3.0.0-M1:0.1.0-SNAPSHOT
+-org.scala-lang:scala3-library_3.0.0-M1:3.0.0-M1 [S]
To summarise, in this section we have shown how it is possible to use a third party library dependency, compiled with Scala 3, from Scala 2.13. If there are issues with changing the binary version of a particular dependency you have, check out the troubleshooting section.
Forward Compatibility
When migrating a subproject to Scala 3, where downstream consuming subprojects are likely to be on Scala 2.13, we would recommend restricting the usage of new features in Scala 3. This will maximise compatibility as you migrate each subproject. However, it is possible to start using some features of Scala 3 without issue, this is due to a limited forward compatibility in Scala 2.13 with some new Scala 3 features.
Forward compatibility means that many definitions created by using new Scala 3 features can be used from Scala 2.13, however they will be remapped to features that exist in Scala 2.13. For example, extension methods can only be used as ordinary methods. So for cross-compatible code we recommend to continue using implicit classes to encode extension methods.
On the other hand, some features of Scala 3 are not mappable to features in Scala 2.13, and will cause a compile-time error when using them. A longer list can be seen in the migration guide, describing how Scala 2 reacts to different Scala 3 features.
For unsupported features, a best effort is made
to report errors at the use-site that is problematic. For example,
match types
are not supported. If we define in the shared
project the type Elem
:
// shared/src/main/scala/example/MatchTypes.scala
package example
object MatchTypes {
type Elem[X] = X match {
case List[t] => t
case Array[t] => t
}
}
and then try to use it in the app
project:
// app/src/main/scala/example/TestMatchTypes.scala
package example
object TestMatchTypes {
def test: MatchTypes.Elem[List[String]] = "hello"
}
we get the following error when calling sbt app/run
:
[error] TestMatchTypes.scala:5:25: Unsupported Scala 3 match type in bounds of type Elem; found in object example.MatchTypes.
[error] def test: MatchTypes.Elem[List[String]] = "hello"
[error] ^
[error] one error found
The error is standard for all unsupported Scala 3 features, naming the feature, the location of the definition and the location where it is used.
Troubleshooting
There are some situations where the steps described in the sections above do not work out of the box for your own project, and some other considerations need to be made.
For the multi module project scenario, let’s say you have two subprojects
A
and B
, where B
depends on A
, both compiled with Scala 2.13.
Source Incompatibilities When Changing to Scala 3
If you change the scalaVersion
of A
to Scala 3 and A
fails to build,
and the error is not due to a missing dependency, the source code of A
may
be incompatible with Scala 3 and you will have to make some changes.
You can consult the Scala 3 Migration Guide
to see what changes may be required.
Missing Scala 3 Dependencies
If you change the scalaVersion
of A
to Scala 3 and A
fails to build
due to a missing dependency, you can try to use the Scala 2.13 version of
the dependency. The sbt-dotty plugin provides
helper methods
for this.
Scala 3 Dependencies That Break Compilation
If you successfully migrate subproject A
to Scala 3, but now B
fails
to compile, there are several likely possibilities:
A
has made a deliberate but forward compatible change to its API, andB
needs to update to use the new API.A
, or a transitive dependency ofA
, uses a feature of Scala 3 that is not forwards compatible with Scala 2.13.A
, or a transitive dependency ofA
, defines a Scala 3 macro but does not provide an equivalent Scala 2 macro (see an example project here for how to provide macros to Scala 2.13 from Scala 3.)- There is a bug in the implementation of reading Scala 3 dependencies in Scala 2.13. Please report here.
Scala 3 Dependencies that Break Runtime Behaviour
If you successfully migrate subproject A
to Scala 3, and B
compiles
successfully, but now fails at runtime, there are two likely possibilities:
- The definitions in the Scala 3 dependency were correctly read, but
perhaps the API changed subtly, so the code of
B
must adapt. For example, perhaps a default implicit value has been replaced, or other code has changed its implementation while using the same signature. - There is a bug in the implementation of reading Scala 3 dependencies in Scala 2.13. Please report here.
Third Party Scala 3 Dependencies that Break Compilation/Runtime
If depending on a third party library, compiled with Scala 3, breaks
compilation, or disrupts runtime behaviour, please consider the above
troubleshooting topics, but instead assume that A
is the third party
library, and B
is your Scala 2.13 project that depends on it.
Contributing
If you have found an issue with Scala 3 dependencies in Scala 2.13 and want to try fixing it in the Scala 2 compiler, you can see a summary of how to work on the implementation here: Working with the Tasty Reader
Summary
In this blog, we have outlined how it is possible to incrementally migrate a project to Scala 3 while continuing to use all the parts together during the transition. We encourage you to try out this process and let us know of any issues with using Scala 3 dependencies from Scala 2.13.