Contents

What Scala has to offer for Java devs

Paweł Stawicki

18 Dec 2024.16 minutes read

What Scala has to offer for Java devs webp image

JVM was introduced in 1994 to run Java programs, but since long ago, it is not the only language you can run on it. There are many other languages leveraging its potential, and Scala is one of them. If you are a Java developer and you have heard or read something about Scala, you might think all it has to offer is some syntactic sugar. In this article, I’ll try to convince you it is much more.

Among other things there is some syntactic sugar too but that is not the subject of this article. I will focus on 3 features that are missing in Java and are IMHO most powerful and making the most difference. It’s type safety, implicit parameters and classes, and information available at compile time.

Type Safety

Is Java type safe? Of course. But what does it mean? Is type safety a “yes or no” question, or maybe it is a kind of spectrum? In other words, is it possible that two languages are both type safe but one is “type safer” than the other? IMHO - yes, it is possible, and Scala is “type safer” than Java. This is because of the “covariance” and “contravariance” mechanisms implemented for generic types. It is a way of creating inheritance relationships between types based on their generic type. It is best explained with an example:

In Java a List of Integers is not a List of Objects, even if every Integer is an Object. This code won’t compile:

List<Integer> listOfInts = new List();
List<Object> listOfObjects = listOfInts // compile time error

However, in Scala this code compiles:

val listOfInts: List[Int] = List()
val listOfObjects: List[Object] = listOfInts

How does it work? It works because in Scala List is covariant in its element (generic) type. Take a look at how List declaration in Scala looks:

sealed abstract class List[+A] extends AbstractSeq[A]

Notice the + sign next to the generic type? This means that List is covariant in its generic type. This means that List of elements of subtype of A is itself a subtype of List of elements of type A. This might still sound unclear, but bear with me. Examples to the rescue:

List of apples is subtype of List of fruits:

val listOfApples: List[Apple] = List(grannySmith, goldenDelicious, gala)
val listOfFruits: List[Fruit] = listOfApples

List of dogs is subtype of List of animals:

val listOfDogs: List[Dog] = List(frenchBulldog, goldenRetriever, germanShepherd)
val listOfAnimals: List[Animal] = listOfDogs

List of any type A is a subtype of List of supertype of A. All these assignments work, the compiler does not complain. Of course it is not possible to assign super type to subtype: val listOfDogs = listOfAnimals does not compile.

This simple mechanism means it is not needed to convert a list of subtypes to assign it to a val of type of list of supertypes.

list of subtypes

Contravariance is an “opposite” rule. It is declared with a minus sign (-), and ParamType[-T] means ParamType is contravariant in its generic type T. That means that a ParamType[A] is a subtype of a ParamType[B] when B is a supertype of A. It might seem like it does not make sense, but there are good use cases for it, too.

An example of a contravariant type is a type accepted by some function (type of function parameter). Such a function, accepting one parameter and returning value, is contravariant in parameter type, and covariant in returned type. Yes, some types (Function1 in this case) can be covariant in one type and contravariant in another one. One parameter function implements Scala trait Function1(simplified here):

trait Function1[-T1, +R]

It is clearly contravariant in one type and covariant in the other. The contravariant type (-T1) is the type of parameter the function accepts, and the covariant one (+R) is the type the function returns. Why is one of them covariant and the other is contravariant? Well, it all makes sense.

Consider the class Apple extending Fruit, and Horse extending Animal. Consider a function with signature Animal => Apple (accepting 1 parameter of type Animal and returning Apple), e.g. whatItLikes(animal: Animal): Applereturning favourite kind of apple for the animal.

Now imagine service HorseService, which in the constructor needs function of type

Horse => Fruit. We can still use the whatItLikes function because of the type's variance! It is contravariant in accepted parameter type - Animal is supertype of Horse, and it is covariant in returned type - Apple is subtype of Fruit - so we can use it! In other words, the function type Function1[Animal, Apple] is a subtype of Function1[Horse, Fruit]., because Animal is a supertype of Horse and Apple is a subtype of Fruit. It makes sense when you think about where you can use it. Like in the example above, when the function is needed that accepts Horse and returns Fruit, we can still use the function that accepts any Animal (because it also accepts any Horse) and returns Apple (because we can always use Apple when Fruit is expected).

It makes sense because, more generally, parameter can be passed to some function which accepts its supertype, and value returned by the function is accepted if it is a subtype of the expected type.

Horse => Fruit 

As Scala developers, we don’t usually need to use type variance in our day-to-day work, but it is used in libraries we leverage, which makes those libraries more useful and safer (in terms of type safety) and saves us some errors in runtime.

Implicits

Implicit means a value, parameter, or function used automatically by the compiler (implicitly) where it is needed, and it doesn’t have to be explicitly used in the code. E.g., to call a method declared like below:

def encode[A](a: A)(using enc: Encoder[A]) 

it is enough to write encode(a) - assuming implicit encoder for type of a exists and is in scope. The compiler will put the enc parameter there for us.

At first glance, implicits seem like syntactic sugar - programmers do not have to use some parameters because those are used automatically by the compiler. However, because of implicit functions (automatic conversion from one parameter to the other), it is much more powerful than it seems.

There are a few types of implicits in Scala 3 - implicit parameters, conversions, extensions, etc. We will quickly glance at them and later we will try to build something useful leveraging various implicits in Scala 3.

Implicit parameter

Allows you to not write the variable name every time it is needed. It is useful for parameters passed from some higher function to lower, and then to another lower one, etc. Usually things like context, default values, timeouts, etc - usually come from the top but are needed even a few levels lower. Instead of passing it around it can be made implicit and then it is passed automatically:

@main
def main(): Unit = {
 given timeout: Timeout = Timeout(3)

 val value = doSomething
 println(value)
}

def doSomething(using Timeout) = {
 doSomethingDeeper
}

def doSomethingDeeper(using timeout: Timeout): String = {
 s"done in less than ${timeout.seconds} seconds"
}

It is not necessary to pass timeout to doSomething or to doSomethingDeeper - it is passed automatically. Implicit parameter, when not used explicitly in some scope, does not have to be named - look at the declaration of doSomething - there is using Timeout without the parameter name only to mark that the implicit parameter is needed but it does not have to be named. In a way it is similar to Java ScopedValue but it has to be declared in every method along the calls path - in the example above doSomething also declares the implicit Timeout parameter, even if it does not use it. On the other hand, implicits are not limited to parameters - implicit classes and methods are also possible - read on.

Implicit conversion

Sometimes it is needed to convert some value to another type. Imagine you need to convert Meters to Yards. It can be done automatically if there is implicit conversion in scope. Then Meters can be used where Yards are expected and it is going to be converted automatically!

case class Meters(value: Double)
case class Yards(value: Double)

given Conversion[Meters, Yards] = m => Yards(m.value * 1.094)

@main
def main2(): Unit = {
 val distance = Meters(10)

 go(distance)
}

def go(distance: Yards): Unit = {
 println(s"Going ${distance.value} yards")
}

Even if go accepts Yards not Meters it still works because of implicit conversion. Meters passed as parameter are converted to Yards.

Extension methods

The third kind of implicits in Scala 3 are extension methods. This can be thought of as “conversion to some type with the method implemented”. Lets assume Doubles are used at some point but we want to be more specific and type safe and make sure some double value represents distance in Meters. Extension method .asMeters can be added to Double type!

extension(distanceInMeters: Double) def asMeters(): Meters = Meters(distanceInMeters)

@main
def main3(): Unit = {
 val distance = 10

 go(distance.asMeters())
}

This way, it is possible to extend behavior (add methods) to a type the programmer does not have a control over - e.g., from a library. There are some other ways to do it, like wrapping the type in ValueObject, but I would argue that extension methods are a better way. Its advantage is that it is possible to have many extension methods (possibly from different libraries) for some type, while it is cumbersome to have many ValueObjects for a single type. If a type is wrapped in one VO, it can not be wrapped in another VO at the same time.Unwrapping and wrapping in another VO is necessary and inconvenient.

Information available at compile time

In Java there is much information available about classes, their fields, methods etc. at runtime. This makes it possible to create generic encoders, serializers etc. but if there is an error or bug - e.g. some field is expected but the class is missing it - it is going to break in runtime. In Scala 3 much more information is available at compile time, so if some assumptions are not met - the code will not compile.

Mirror

Information about case class (case class is like record in Java) and its field types is provided by Mirror.Of[A] - this is type constructed at compile time which contains the information as other types - so all here is on type level, not on value level, and available at compile time. It can be used to get case class type name, its field types, labels etc.

constValue

constValue is a method from scala.compiletime package, and it returns a value of a constant type. What is constant type? Well it is a type with only one possible value. Actually, when a value is created in Scala 3 like

val a = 1

Type of a is Int, but it is also 1. Yes 1 in this case is type, obviously it extends Int, and usually we do not need to use it, we use just Int, but still it is a separate type. This is a constant type with only one possible value: 1. So constValue is a method that returns a value, or in other words an instance (only one possible), of such type.

constValue can be used only in inlined context as the type passed has to be known at compile time. We will get to inline later.

erasedValue

erasedValue is also from scala.compiletime and it creates “virtual instance” of type at compile time. Useful when information is needed about some type whose value does not even exist at runtime. erasedValue creates such virtual value. We will use it to pattern match on case class type members from Mirror.Of.

inline

Inline method is a method for which the compiler replaces its call with the method body. We will use inline match in our example which is reduced at compile time to only a single pattern-match branch. So e.g.

inline b match {
 case true => "matches!"
 case false => "no match, sorry"
}

is going to be replaced with simple "matches!" or "no match, sorry" at compile time. Of course the value of b must be also known at compile time for that to work, in other case the compiler is going to throw an error.

Let’s build something useful

At first glance, implicits might seem simple syntactic sugar, saving us only some typing.However, when used wisely, it can be a powerful tool. It can also be harmful when used improperly - you’ve been warned ;) An example of implicits use (in a good way) is generation of encoders/decoders and serializers/deserializers. Most often used are JSON encoders/decoders but it can be any other format like CSV, Protobuf etc,. JSON is a good candidate because it is schema-less and allows nested objects so it makes an opportunity to show how using implicits can be useful to generate nested encoders. In this example we will create something simpler but schemaless and with nesting just like in JSON. The encoder is generic and can be used for any case class (case class is like record in Java, remember?) with other nested case classes. Well maybe not any - traits are not supported nor collections etc., but it is enough to show what implicits can do. Consider two simple case classes

case class User(name: String, age: Int, address: Address)

and

case class Address(country: String, city: String, street: String, number: Int)

We are not going to use any reflection at runtime. If there is something wrong and some instance cannot be encoded, let's find out in compile time.

The goal is also to make it easy to use, something along those lines:

val user = User("Pawel", 45, Address("Polska", "Szczecin", "Kwiatowa", 10))
val encoded = Encoder.encode(user)

Apparently, we need Encoder object with encode method

object Encoder {
   def encode[A](value: A)(using enc: Encoder[A]): String = ...

object in Scala is a singleton. Put there methods that would be static in java - the ones that do not need class instances. The encode method declaration means it accepts one “regular” parameter value of type A, and one implicit parameter of type Encoder[A].

A is generic type in the context of this method. It can be any type and in the declaration it is needed for the enc parameter. The enc is Encoder parametrized with generic type and this has to be the same type that value is instance of. This is the only restriction on generic type A here and the only reason we need to use it - to show that Encoder has to have generic type matching value type.

enc is in a separate parameters list - that’s right, in Scala it is possible for a method to have more parameter lists than one. using keyword means parameters in this list are implicit - i.e. we don’t have to type them if an implicit parameter of that type is in scope.

Let’s fill in the method body. With the required encoder in place it seems easy:

def encode[A](a: A)(using enc: Encoder[A]): String = enc.encode(a)

This way implicit encoders of proper type are used - this becomes handy when nested types are encoded.

So there must be an implicit Encoder for type A in scope but where does it come from? It is provided by implicit method

inline implicit def makeEncoder[A](using m: Mirror.Of[A]): Encoder[A]

For the method body, we need some tools described before. The purpose of this encoder is to print the class name and names and values of all its members - like in JSON but simpler, braces are omitted and we do not care about formatting. All of this information is accessed in compile time. Lets look at the methods to do it:

inline implicit def makeEncoder[A](using m: Mirror.Of[A]): Encoder[A] = (a: A) => {
 val labels: Seq[String] = deconstructLabels[m.MirroredElemLabels]

 val memberEncoders: Seq[Encoder[Any]] = deconstructMemberEncoders[m.MirroredElemTypes]

 val lines = a.asInstanceOf[Product].productIterator.zip(labels).zip(memberEncoders).map {
   case ((value, label), memberEncoder) =>
     s"$label: ${memberEncoder.encode(value)}"
 }

 constValue[m.MirroredLabel] + "\n" + lines.mkString("\n")
}

The makeEncoder method uses some helper methods to get case class fields, their names, types, and the name of the case class, and finally it creates a pretty String containing all this information.

labels contain labels of members so e.g. for Address those are going to be “country”, “city”, “street” and “number”.

memberEncoders contains encoders for all the case class members, in order.

One interesting thing here is a.asInstanceOf[Product]. All case classes in Scala are also instances of Product. The Product trait contains productIterator - iterator containing all the fields of case class. After zipping with labels (field names) and memberEncoders we can process each field one by one, and for each of them we have value, label and memberEncoder.

The last line of the makeEncoder method simply adds m.MirroredLabel which contains the case class name.

Let’s take a look at the last missing part - deconstructLabels and deconstructMemberEncoders methods.

inline def deconstructLabels[T <: Tuple]: List[String] =
 inline erasedValue[T] match
   case _: (head *: tail) =>
     constValue[head].asInstanceOf[String] :: deconstructLabels[tail]
   case _: EmptyTuple => List.empty

The above methods create “virtual” (it doesn’t really exist at runtime) value of type T which in this case is MirroredElemLabels from Mirror.Of. Then it pattern matches its type. The type is Tuple and it is possible to match on it in a similar fashion like on the list - head and tail (tail can be an empty list) or EmptyTuple. By recurrency when it is head and tail (bear in mind those are both types, not values, remember constant types?) read head as an instance of String (those are labels, so there are always Strings) and deconstruct tail. This way a list of all the labels is created.

Take a look at another method - deconstructMemberEncoders. It is a bit more complicated but stay with me.

inline def deconstructMemberEncoders[T <: Tuple]: List[Encoder[Any]] =
 inline erasedValue[T] match
   case _: (head *: tail) =>
     val encoder = summonInline[Encoder[head]]
     encoder.asInstanceOf[Encoder[Any]] :: deconstructMemberEncoders[tail]
   case _: EmptyTuple => List.empty

Like before we need to use erasedValue to match on T type. Like before generic type T is always a tuple but this time this is a tuple of field types not labels. So those are not all Strings but types like Int, String, Address etc. For every member type summon Encoder is summoned. Summoning is getting implicit value if it is in scope. If it is not it results in compilation error - all implicits are resolved in compile time not run time. Encoders for primitives (for simplicity we use only String and Int) must be in scope too and we add them explicitly. Yes… implicits explicitly… Well for those two we need such lines:

given Encoder[String] with
   def encode(a: String): String = a

given Encoder[Int] with
   def encode(a: Int): String = a.toString

This creates encoders for String and Int and by using a given keyword puts them in scope as implicits. Encoder for Address is also in implicit scope even if we never create it! It is created automatically by the implicit method makeEncoder. This is what I mean by saying that some “implicits are added to scope explicitly”. We need to create some of them by hand, while the one for Address is going to be created by our magical method.

With all these pieces in place a tool to encode every case class containing String or Int members and other nested case classes containing also fields of those types is ready. All of this in compile time! Guaranteed that when it compiles, call to Encoder.encode(x) always returns pretty string containing all the information about x.

Summary

Scala offers some unique features not present in Java that go far beyond mere syntactic convenience. Key features are advanced type system, allowing for variance in generics and enabling more robust polymorphism. Implicits provide automatic parameter passing, type conversions and generation, making it possible to generate type classes for nested types. Scalas’ compile-time capabilities, like inline functions providing information about types, help catch errors early and optimize performance. Together, these features make Scala a compelling choice for Java developers seeking a more expressive and reliable programming experience.

Code: https://github.com/amorfis/scala-for-java-devs

Reviewed by: Sebastian Rabiej, Łukasz Lenart

Check:

Blog Comments powered by Disqus.