Scala 3: these 10 major changes could influence your everyday coding

Alejandro Picetti
Globant
9 min readMar 23, 2022

--

New version, many changes…

Scala 3 has been around for a little while now (it’s at version 3.1.1 at the time of writing this). If you come from Scala 2 and want to jump into version 3, either out of curiosity or due to an incoming project, this article will show you 10 major changes to language’s syntax and constructs that will influence your everyday’s coding tasks.

1: Function as program entry point

Usually a Scala 2 program’s entry point looks like this:

object MyScalaMainProgram{
def main(args: Array[String]): Unit = {
println(s"Hello ${args(0}}!")
}
}

or this:

object MyScalaMainProgram extends App {
println(s"Hello ${args(0}}!")
}

In addition, Scala 3 defines a function as our program’s entry point by using the @main annotation, like this:

@main def mainProgramFunction(name: String): Unit = {
println(s"Hello $name!")
}

Note that the function has typed parameters so, if string representations of proper values are provide in the command-line, they will be automatically converted to their corresponding parameters’ data types (of course, this is a very simple example with just one string argument). This feature helps avoiding all boilerplate code related to the parsing of simple command-line parameters.

In case you want the old-style string values from command line, you can write the function using varargs syntax:

@main def mainProgramFunction(args: String*): Unit = {
println(s"Hello ${args(0)}!")
}

2: Optional indentation-based syntax

Scala 3 offers a new, alternative (but optional) syntax based on indentation rather than braces for expressions such as if-else, for-comprehensions, pattern matching, and functions. This new syntax, allegedly, has been included to appeal non-Scala audiences, namely Python developers. Here we have some examples:

// for-comprehension, cycle style
for
i <- 1 to n if n % 2 == 0
do
println(i)
println("-------")
end for
// for-comprehension with yield
val result =
for
i <- 1 to 5
j <- 1 to 5
yield
val z = i * j
z
// You can also use 'end for' here if you like
// if-else, control structure style
if n % 2 == 0 then
println("Yes")
println(s"$n is an odd number")
else
println("Nope")
println(s"$n is not an odd number")
end if
// if-else, expression style
if n % 2 == 0 then
"Yes"
else
"Nope"
// You can use 'end if' here if you like
// Class with brace-less syntax
class Point2D(val x: Double, val y: Double):

def distance: Double = sqrt(pow(x, 2.0) + pow(y, 2.0))

def angle: Double = angleForQuadrant(x, y) + 180 / Pi * atan(y/x)
// A method with a sample pattern matching
private def angleForQuadrant(x: Double, y: Double) =
(x.sign, y.sign) match
case (1.0, 1.0) => 0
case (-1.0, _) => 180
case (1.0, -1.0) => 360

end Point2D

You may have noticed the end word followed by if, for or even a class or function/method name. That is called end marker, and it’s used in cases where the code block is long in order to help the reader distinguish where that code block ends, thus avoiding confusion; it’s completely optional, as shown on above samples.

3: Extension methods instead of implicit conversions

Frequently, we have a type/class whose code is not under our control, for instance a class in a third party library, but we would like it to have a method that it currently doesn’t have. In those cases we resort to implicit conversions.

For instance, let’s say we want to have a method Int.times() to execute an action as many times as the value of the Int. We first create an implicit class inside a package object:

package object conversion {
implicit class EnrichedInt(val n: Int) {
def times(action: => Unit): Unit = for {_ <- 1 to n } action
}
}

Then we use in our code:

10.times { println(s"Hello, ${args(0)}") }

Instead, Scala 3 features extension methods. Let’s see how above extra method would be added in Scala 3:

extension (n: Int) def times(action: => Unit): Unit =
for _ <- 1 to n do action

Much less boilerplate code. Additionally, extension methods don’t need a package object to contain them (more on this in the next section).

4: Top-level definitions instead of package objects

Package objects in Scala let us hold package-wide definitions such as:

  • Type aliases
  • Constant values / objects
  • Implicit classes to add methods to existing type (see previous section)
  • Function that do not apply as methods in classes

In Scala 3, however, all these are considered top-level definitions, so what we have to do is simply define them in a Scala file, like below:

package io.picetti.scala3.article//We already know this: extension function
extension (n: Int) def times(action: => Unit): Unit =
for _ <- 1 to n do action

// Immutable variables
val X_COORD: Double = 35.5
val Y_COORD: Double = 45.2

def fizzBuzz(num: Int) =
num match
case n if n % 15 == 0 =>
print("Fizzbuzz")
println("!")
case n if n % 5 == 0 =>
print("Buzz")
println("!")
case n if n % 3 == 0 =>
print("Fizz")
println("!")
case _ =>
println(num)
end match

5: Union and Intersection types

Let’s consider the following function written in Scala 2:

def divideXByY(x: Int, y: Int): Either[String, Int] = {
if (y == 0) Left("Dude, can't divide by 0")
else Right(x / y)
}

It uses the Either[E,V] type to return either a result or an error message. Same result can be obtained in Scala 3 by using union types. This kind of types let you define that a returned type or a provided parameter is of type A or B or … . Let’s use this in above example instead of Either:

def divideXByY(x: Int, y: Int): String | Int =
if y == 0 then "Dude, can't divide by 0" else x / y

Surely this example is trivial and does not reflect the power of union types. However, what if we wanted to extend the logic of this function so that it returns:

  • The exact division of both Ints if they can be divisible
  • The exact division with decimals in case both Ints are not divisible
  • The usual error message in case divisor is zero.

In that case, the function could be expressed like this in Scala 3:

ef divideXByY(x: Int, y: Int): String | Double | Int =
if y == 0 then
"Dude, can't divide by 0"
else if x % y != 0 then
(x * 1.0) / (y * 1.0)
else x / y

This case can’t be handled with Either[E,V] without over-complicating the code since Either handles just 2 types (I’ll leave the implementation of above code using Either to the reader as an exercise)

Similarly, intersection types let us define either a return type or provided parameters as being of type A and B and … . Let’s take a look at the following code:

trait Point2D:
val x: Double
val y: Double

def draw: Unit

trait Polarizable:
def distance: Double
def angle: Double

class PolarPoint(
override val x: Double,
override val y: Double
) extends Point2D with Polarizable:

override def draw: Unit =
println("Let's assume the point is drawn here")
end draw

override def distance: Double = sqrt(pow(x, 2.0) + pow(y, 2.0))

override def angle: Double =
angleForQuadrant(x, y) + 180 / Pi * atan(y/x)

private def angleForQuadrant(x: Double, y: Double) =
(x.sign, y.sign) match
case (1.0, 1.0) => 0
case (-1.0, _) => 180
case (1.0, -1.0) => 360

end PolarPoint

Then a function like the following one could be possible:

def drawPolar(point: Point2D & Polarizable): Unit =
println(
s"""
|Drawing Point(${point.x},${point.y})
|with polar distance: ${point.distance}
|and angle: ${point.angle}
|""".stripMargin)

No matter which classes of points we define, this function will accept only those extending both Point2D AND Polarizable.

6: given/using instead of implicit values and parameters

Scala’s implicits are a multi-faceted feature in Scala 2; using them you can define, among others:

  • Context parameters
  • Extensions to existing types with new methods
  • Implicit conversions

However, this means the keyword implicit has many uses and makes code somewhat confusing, even for the compiler, causing unexpected effects. That’s why Scala 3 has this single feature split into a set of features:

  • Extension methods (see Section 3)
  • Implicit conversions require explicit definitions (i.e. a bit more work)
  • Context parameters with keywords given / using

We will concentrate on the latter. Turning back to above Point2D example trait, let’s suppose we want to make the draw method to draw a point on different devices, each of them modeled as a GraphicContext whose implementation may vary in different parts of our program. So we redefine our draw method like this:

trait Point2D(val x: Double, val y: Double):
def draw(using ctx: GraphicContext): Unit

Note the using keyword that indicates it must be provided with a contextual value. This value can be defined either in a class or in a package/sub-package as a top-level definition as we used to do with implicits:

given ctx: GraphicContext = GraphicContext()

Note the given keyword to indicate a contextual value. The above definition can be written also like an anonymous contextual value, like this:

given GraphicContext = GraphicContext()

7: Trait parameters

In Scala 3, traits can have parameters, same as classes. For instance, the above example in Section 5 defines the trait Point2D:

trait Point2D:
val x: Double
val y: Double

def draw: Unit

Same trait could be written as:

trait Point2D(val x: Double, val y: Double):
def draw: Unit

And class PolarPoint should be properly changed as:

class PolarPoint(
override val x: Double,
override val y: Double
) extends Point2D(x,y) with Polarizable:
// Rest of class definition

At this point, you could say: “Well? Why not an abstract class Point2D?”. I acknowledge the example is trivial because it’s intended to be didactic, but remember that abstract classes work only for single inheritance, while traits let you compose in a “multiple inheritance”-like manner.

8: Universal apply methods: ‘new’ keyword not needed

In Scala 2, as we already know, instances of a case class can be created without using the keyword new because compiler adds an apply() method to the class, among other methods automatically added to it. Scala 3 extends the addition of an apply() method to all concrete classes. That means an instance of class PolarPoint on above code snippets could be created simply as:

val polarPoint = PolarPoint(X_COORD, Y_COORD)

(btw, X_COORD and Y_COORD come from snippet on Section 4)

9: Enumerations

So far, in order to create an enumeration we had to resort to Java enums or a third-party library. Now Scala 3 has its own Enumeration type. We can define a simple enumeration like this:

enum Currency:
case Dollar, Yen, Euro

Or something with more information using parameterized enumerations:

enum Currency(val code: String):
case Dollar extends Currency("USD")
case Yen extends Currency("JPY")
case Euro extends Currency("EUR")

And a few things we can do with the enumeration we just defined:

val currency = Currency.Dollarprintln(currency.code)
println(Currency.valueOf("Yen").ordinal)

TBD: Sum types

10: Parameter untupling

In cases where we have lists of tuples and need to, say, map it to another list using tuples’ values we usually had two ways. The first one is to access a tuple’s elements like this:

val points = List(
(PolarPoint(100,100), PolarPoint(20,200)),
(PolarPoint(100,100), PolarPoint(100,200)),
)
val sumPoints = points.map { pointPair =>
(pointPair._1.x + pointPair._2.x, pointPar._1.y + pointPair._2.y)
}

However, in non-trivial code, this is considered a bad practice because reader has no way to know what’s in _1 or _2. It gets worse if the code that produces the list of tuples is not obvious and someone changes the order of data in the tuple. So, we have a second alternative: de-structuring the tuple with a case:

val sumPoints = points.map { case (p1, p2) =>
(p1.x + p2.x, p1.y + p2.y)
}

Much better, but now the case suggest pattern matching that could fail if not exhaustive. Scala 3 offers a clearer alternative: parameter un-tupling. Let’s see how the previous snippet would look like:

val sumPoints = points.map {
(p1, p2) => (p1.x + p2.x, p1.y + p2.y)
}

Tuple will be unstructured into it’s components without using pattern matching’s case.

Conclusion

We have covered 10 major changes in Scala 3 that will affect your everyday coding tasks in case you start a new project in Scala 3. Of course, there are more things involved, like support on tool-chain, libraries and frameworks, but I hope this article will let you have a first glance at writing code in Scala 3.

--

--