What Scala has to offer for Java devs
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.
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): Apple
returning 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.
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 Double
s 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 inline
d 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: