A Guide to Scala 3
Scala 3 comes with many amazing new features. This article attempts to explain the most notable ones, so it is by no means comprehensive. It is, however, a very good introduction to these concepts for beginner to intermediate-level Scala programmers.
At the time of this writing, Scala 3 isn’t actually officially released. However, all the features that would be in Scala 3 are available in Dotty, so we’ll be using it instead.
The examples in this article are on GitHub, and in order to run them, you can clone the repository and run sbt console
:
git clone https://github.com/Tabzz98/guide-to-scala3-examples.git
cd guide-to-scala3-examples/
sbt console
If you use Visual Studio Code, and you have it on the
PATH
(i.e. thecode
command is available globally,) you can runsbt launchIDE
to get a better editing experience.
Intersection Types
Consider the following definitions:
trait A {
val a: String
}trait B {
val b: Int
}
If we think of types in terms of sets, a type would be a collection of elements that satisfy certain properties. For an element to be a member of type A
, it has to have the property a
of type String
. The same goes for elements of type B
.
Since types are just sets, what would the intersection of A
and B
be? Well, it’s the set of elements that satisfy the properties of both A
and B
. We can denote such a type as A & B
(the intersection type of A
and B
,) and we can use it to specify data types such as the type of the argument c
in:
def f(c: A & B) = c.a + " & " + c.b
We can create an instance of A & B
by creating a subtype of both A
and B
:
case class C(a: String, b: Int)
extends A with B
Which can be passed to f
:
scala> f(C("Some String", 42))
val res0: String = Some String & 42
Intersection types are actually the equivalent of compound types, so both A & B
and A with B
are the same type. However, the with
syntax will be deprecated in future versions of Scala.
Union Types
In Scala, we can express that a value can be either of type A
or of type B
as Either[A, B]
. Consider this example:
val a = new A { val a = "Some String" }
val b = new B { val b = 42 }type E = Either[A, B]
val l: E = Left(a)
val r: E = Right(b)
The Either
type has two variants: Left
, and Right
. In the case of E
, Left
represents the variant that carries a value of type A
, while Right
carries a value of type B
(notice the order of the type arguments passed to Either
.)
Let’s say that we want to define a function g
of E
. In order to deal safely with values of E
, we would need to do some pattern matching:
def g(e: E): String = e match {
case Left(a) => s"String value a: ${a.a}"
case Right(b) => s"Int value b: ${b.b}"
}
This is great! We can safely express values that belong to one of two types. But there are a few problems with Either
:
- It’s not commutative (
Either[A, B]
is notEither[B, A]
) - It sucks for working with values that can be of three or more types (I mean,
Either[A, Either[B, Either[Float, Boolean]]]
? This is a nightmare!)
While still thinking of types as sets, let’s think of Either[A, B]
as the union of the sets A
and B
(the set containing all elements of both A
and B
.) This union can be expressed as A | B
:
type U = A | B
Union types solve the two problems listed above, and they’re actually more pleasant to look at and deal with. Consider the definition of h
:
def h(v: A | Double | B | Boolean): String = v match {
case a: A => s"String value a: ${a.a}"
case d: Double => s"Double value d: $d"
case b: B => s"Int value b: ${b.b}"
case bool: Boolean => s"Boolean value bool: $bool"
}
Which can be applied with simple arguments (no need to wrap in Left
or Right
.) For example:
scala> h(true)
val res0: String = Boolean value bool: truescala> h(a)
val res1: String = String value a: Some Stringscala> h(42.22)
val res2: String = Double value d: 42.22
Enums
Enums are definitions of types’ values by name. They’re useful when a value of a particular type can be one of a well-defined finite set of elements. For example, the definition of WeekDay
:
enum WeekDay {
case Sunday, Monday, Tuesday, Wednesday,
Thursday, Friday, Saturday
}
Enum values can be parameterized in order to implement algebraic data types (products, to be specific.) In Scala 2.x, we encode ADTs as case classes (or tuples,) and sealed traits. Let’s consider a definition of logical expressions:
sealed trait VerboseLogicalExpression {
import VerboseLogicalExpression._
def eval: Boolean = this match {
case ConstFactor(c) => c
ase NotFactor(c) => !c
case Term(l, Some(r)) => l.eval && r.eval
case Term(l, None) => l.eval
case Expr(l, Some(r)) => l.eval || r.eval
case Expr(l, None) => l.eval
}
}object VerboseLogicalExpression {
sealed trait Factor extends VerboseLogicalExpression
case class ConstFactor(value: Boolean) extends Factor
case class NotFactor(value: Boolean) extends Factor
case class Term(left: Factor, right: Option[Factor])
extends VerboseLogicalExpression
case class Expr(left: Term, right: Option[Term])
extends VerboseLogicalExpression
}
Now we can evaluate the expression:
scala> import VerboseLogicalExpression._scala> Expr(Term(ConstFactor(true), Some(NotFactor(false))), Some(Term(ConstFactor(false), None))).eval
val res0: Boolean = true
Which is just true ∧ ¬false ∨ true
. Notice how we’ve had to define a case class for each variant of VerboseLogicalExpression
, and made it extend the trait. We also did the same with Factor
just to get the behavior of ConstFactor | NotFactor
.
Using enums, we can define logical expressions as:
enum LogicalExpression {
case ConstFactor(value: Boolean)
case NotFactor(value: Boolean)
case Term(
left: ConstFactor | NotFactor,
right: Option[ConstFactor | NotFactor]
)
case Expr(left: Term, right: Option[Term]) def eval: Boolean = this match {
case ConstFactor(c) => c
case NotFactor(c) => !c
case Term(l, Some(r)) => l.eval && r.eval
case Term(l, None) => l.eval
case Expr(l, Some(r)) => l.eval || r.eval
case Expr(l, None) => l.eval
}
}
Much cleaner! Note that using the apply
method of an enum’s variant would return a value of the enum type, not the specific case type. We can use new
to use the constructor of the specific type. So we would define a value of type LogicalExpression.Expr
as:
import LogicalExpression._val expr = new Expr(
new Term(new ConstFactor(true), Some(new NotFactor(false))),
Some(new Term(new ConstFactor(false), None))
)
Givens
Givens are definitions that can be used implicitly. In many ways, they are a more refined version of Scala 2.x’s implicits. Consider this example:
trait Add[T] {
def add(x: T, y: T): T
}given Add[C] {
def add(x: C, y: C) = C(x.a + y.a, x.b + y.b)
}
Here, we define a given instance of an Add[C]
. This is a more straightforward way of implementing typeclasses.
Givens do not need to have names, since their type is all that matters in most cases.
We can define functions that have given parameters to summon any defined given instance:
def zipAdd[T](xs: List[T], ys: List[T])(given a: Add[T]): List[T] =
xs.zip(ys).map(a.add)scala> zipAdd(List(C("Welcome ", 18), C("Scala ", 0)),
| List(C("to", 22), C("3", 2)))
val res0: List[C] = List(C(Welcome to,40), C(Scala 3,2))
Similarly, we can define given value aliases:
given Int = 42def addMagicNumber(x: Int)(given magicNumber: Int) = x + magicNumberscala> addMagicNumber(2)
val res0: Int = 44
Implicit conversions are replaced with the definition of a given instance of Conversion[T, U]
. For example, we can define an implicit conversion from String
to A
as:
given Conversion[String, A] {
def apply(s: String) = new A { val a = s }
}scala> h(a) // a is converted from A to String
val res0: String = String value a: Some Stringscala> :type h
A | Double | B | Boolean => String
Using wildcard syntax in imports will not import any given definitions. Instead, you must use .given
syntax, or import the given definitions by name. See here for more information about imports.
Extension Methods
Scala 3 offers a really simple way to add new methods to existing types. Consider the following example:
def (x: T) +[T] (y: T)(given a: Add[T]) = a.add(x, y)
This definition (when in scope,) adds the +
method to any type T
for which a given instance of Add[T]
is defined, such as the type C
.
scala> C("Yay ", 2) + C("Simplicity!", 40)
val res0: C = C(Yay Simplicity!,42)
In 2.x, you would have to define an implicit class with the extension method defined on it to achieve this. This is a much simpler approach.
Conclusion
There still are many features not covered here: typeclass derivation, match types, and an entirely new macro system, just to name a few. This article would need to be much longer than this in order to cover all of them. Luckily, the documentation offers a great introduction to these concepts, even though it might be lacking in some regards, but that’ll surely get better with the official release of Scala 3.
I hope this article was insightful for you. Consider following Heavenly-x on Twitter and here on Medium to get notified of any new content.