1. Overview
Scala 3 uses Dotty, a next-generation compiler for Scala that implements many language features and improvements.
In this article, we’re going to introduce Dotty and its fundamentals. Also, we’ll give an overview of some of the important changes that Scala 3 has introduced.
2. What Are DOT and Dotty?
The primary goal of the Scala programming language is to provide a synthesis of three elements:
- Types
- Functions
- Objects
Other languages covered a subset of these elements – for example, Common Lisp (function and object), Java (type and objects), Haskell, and ML (functions and types) – but Scala is the first language whose goal is to support the synthesis of these three elements.
Scala 3 will be a big step towards realizing the full potential of the fusion of OOP and FP at a typed level. So, the DOT Calculus is the foundation behind this fusion (DOT = OOP + FP).
The DOT, which stands for Dependent Object Types, has been introduced as the foundation of Scala 3. The idea behind DOT is to capture the essence of Scala and make progress only with precise and strong foundations.
Dotty is a compiler for the development of Scala 3 with DOT Calculus. DOT is a type-calculus used to prove that Dotty’s language specification and its type system are sound. DOT validates the language specification correctness formally. The compiler research team has been developing this to have a core and sound understanding of the foundation of Scala 3’s type system.
DOT is a small programming language with a type system. It is small but very expressive. It’s mostly a Scala subset in which other features can be encoded. It is simpler than Scala, and all Scala code can be desugared to the DOT calculus.
Scala 2 and 3 are fundamentally the same languages, but the compiler behind Scala 3 is based on Dotty work. The binary name for Scala 2’s (and below) compiler is scalac, but for Scala 3, the compiler name is dotc.
Scala 3 has dropped some unsound and useless features to make the language smaller and more regular. It has added some new constructs to increase its expressiveness. Also, it has changed some constructs to remove warts and increase simplicity, consistency, and usability.
The range of changes is large, but the ordinary Scala 2 code will also work on Scala 3. In this article, we enumerate the most important changes.
3. Type System
Scala 3 is going to have a simple, sound, and consistent type system. Let’s review some important changes in its type system:
3.1. Existential Types
*Existential Types (T forSome { type X}) and Type Projections (T # A) are dropped.* They were unsound and made a type system more difficult to interact with other constructs. Note that projection on concrete types is still supported.
3.2. Intersection and Union Types
Compound Types (T with U) replaced with Intersection Types (T & U) and Union Types (T | U). With both unions and intersections, Scala’s subtype hierarchy becomes a lattice, which helps the compiler to easily find the least upper bounds and greatest lower bounds. As a result, the compiler is more sound when inferring data types.
Let’s define a parse method that returns a union type:
def parse(input: String): Int | String =
try
input.toInt
catch
case _ => "Not a number"
A union type A | B comprises all values of type A and also all values of type B. Unions are duals of intersection types. A | B contains all members/properties that A and B have in common. They are commutative, so A | B is the same type as B | A. We can use pattern matching to decide if A | B is A or B.
Let’s see an example of intersection types:
trait Show:
def show: String
trait Closable:
def close: Unit
type Resource = Show & Closable
def shutdown(resource: Resource) =
println(s"Closing resource ${resource.show}")
resource.close
object res extends Show, Closable:
override def show = "resource#1"
override def close = println("Resource closed!")
shutdown(res)
Type A & B represents values that are of type A and B at the same time. A & B has all members/properties of A and all members/properties of B. Intersection is commutative, which means A & B is the same as B & A, but the with compound type isn’t commutative. In the long run, they will be replaced by compound types.
3.3. Type Lambda
*Type Lambda is introduced with a nice syntax, [X] =>> F[X].* It’s a higher-kind type that takes a type parameter X. This essentially gives us a type T, which typically will refer to this variable X.
They are functions from types to types:
trait Monad[F[_]] {
def pure[A](x: A): F[A]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
}
trait EitherInstance[E] extends Monad[[T] =>> Either[E, T]] {
override def pure[A](x: A) = ???
override def flatMap[A, B](fa: Either[E, A])(f: A => Either[E, B]) = ???
}
The previous emulation of type lambda in Scala 2 using structural types is awful and uses general type projection operator #, which has been eliminated:
trait EitherInstance[E] extends Monad[({ type T[X] = Either[E, X] })#T] {
override def pure[A](x: A) = ???
override def flatMap[A, B](fa: Either[E, A])(f: A => Either[E, B]) = ???
}
4. Traits
So far in Scala 2, we didn’t have a way to pass parameters to a trait. There was a workaround to cope with this problem: We could make the trait an abstract class and fill its parameters in the subclass.
This solution works as far as we don’t run into problems with early initialization. The problem is that the parameters of the abstract class are initialized after the evaluation of the subclass constructor.
With Scala 3, we don’t run into these problems since we can pass parameters into traits:
trait Base(val msg: String)
class A extends Base("Foo")
class B extends Base("Bar")
Traits, like classes, can have parameters. Their arguments are evaluated before the trait is initialized.
5. Enums
Before Scala 3, writing enumeration was awkward. Also, we had a lot of boilerplate to write a simple ADT. There was a lack of expressiveness. Scala 3 has a built-in enum keyword that helps us write enumerations and ADTs. This one construct supports both enumeration and ADTs.
With enum, we can define a type comprising a set of named values. Let’s suppose we want to write a data type name Color that contains a fixed number of named values:
enum Color:
case Red, Green, Blue
To make our enum compatible with Java enums, we can extend java.lang.Enum:
enum Color extends java.land.Enum[color]:
case Red, Green, Blue
They can be parameterized:
enum Color(code: String):
case Red extends Color("#FF0000")
case Green extends Color("#00FF00")
case Blue extends Color("#0000FF")
Also, we can write very expressive ADTs with enums:
enum Option[+T]:
case Some(x: T)
case None
6. Implicits
Implicits have too many puzzlers in Scala 2 — there’s too much head-scratching. Sometimes, they’re hard to understand, error-prone, easily misused, or overused, with many rough edges. Some of that’s unavoidable because it’s an advanced programming topic, while some of it is avoidable and just plain annoying.
In Scala 2, implicit have a variety of use cases. Let’s review some of the most important ones:
- Providing Contextual Environment
- Writing Type Class Instances
- Extension Methods
- Implicit Conversions
Implicits in Scala 2 convey mechanism over intent. Scala 3 focuses on intent rather than the mechanism by introducing new keywords like given and using. Note that for compatibility issues, Scala 2 implicits will remain available in parallel for a long time.
6.1. Providing Contextual Environment
The context consists of external information/parameters that can be implicitly understood by our program.
In Scala 2, we used implicit parameters to pass contextual environment into our programs. They are a fundamental way to abstract over the context.
For example, if we have a program that needs ExecutionContext, we create an implicit parameter for this type:
import scala.concurrent._
import scala.concurrent.duration._
implicit val ec: scala.concurrent.ExecutionContext =
ExecutionContext.global
def square(i: Int)(implicit val ec: ExecutionContext): Future[Int] =
Future(i * i)
In Scala 3, the using keyword allows us to pass the contextual environment to the function implicitly. Also, the given keyword helps us to provide an instance of that contextual type:
import scala.concurrent.ExecutionContext
import scala.concurrent.Await
import scala.concurrent.duration._
given ExecutionContext =
ExecutionContext.global
import scala.concurrent.Future
def square(i: Int)(using ec: ExecutionContext): Future[Int] = {
Future(i * i)
}
6.2. Writing Type Class Instances
One of the most important use-cases of Implicits in Scala 2 is to define an instance of type classes:
trait Ord[T] {
def compare(x: T, y: T): Int
}
implicit intInstance: Ord[Int] = new Ord[Int] {
override def compare(x: Int, y: Int) =
if (x < y) -1 else if (x > y) +1 else 0
}
Scala 3 has a special keyword for writing an instance of a type class, by using the given keyword:
trait Ord[T]:
def compare(x: T, y: T): Int
given Ord[Int] with
override def compare(x: Int, y: Int) =
if (x < y) -1 else if (x > y) +1 else 0
6.3. Extension Methods
Writing extension methods in Scala 2 has some boilerplate. We should write a wrapper class for that type and then, by using the implicit function, wrap that type into the new extended class:
import scala.language.implicitConversions
class RichInt(i: Int) {
def square = i * i
}
object RichInt {
implicit def richInt(i: Int): RichInt = new RichInt(i)
}
Scala 3 has a special keyword – extension – for writing extension methods that is simple and has a concise syntax:
extension (i: Int) def square = i * i
6.4. Implicit Conversions
In Scala 2, implicit conversions could sometimes be a source of unexpected bugs and issues, so we apply extra caution when using them.
Let’s write an implicit conversion that converts a String into Int with Scala 2 syntax:
import scala.language.implicitConversions
implicit def string2Int(str: String): Int = Integer.parseInt(str)
def square(i: Int) = i * i
In Scala 3, to provide implicit conversion from type A to B, we need to provide an instance of Conversion[A, B]:
import scala.language.implicitConversions
given Conversion[String, Int] = Integer.parseInt(_)
def square(i: Int) = i * i
Implicit conversions in Scala 3 are hard to misuse. They eliminate surprising behaviors. Using this method is less error-prone than the same method in Scala 2.
7. Conclusion
In this article, we reviewed some important changes in Scala 3. It is a complete overhaul of the Scala language that preserves the compatibility with Scala 2 in most cases.
Its type system becomes more mature and principled. The language becomes more opinionated by introducing new keywords and constructs.