1. Overview

In this tutorial, we’ll learn about the new equality feature introduced in Scala 3 known as multiversal-equality. We’ll briefly look at the disadvantages of universal equality and the need to overcome them.

We shall delve into some of the key details and practical aspects of its applications with the help of a few simple examples.

2. Disadvantages of Universal Equality

Scala is a strongly and statically typed language. But there are certain areas where it lacks type-safety. Equality is one of those. Up until Scala 2, we have universal equality similar to other programming languages like Java. Universal equality or loose equality lets us compare any two variables to each other, even if they’re of different types. This may lead to several unintended problems in our programs.

Let’s look at a scenario where we compare two objects belonging to different classes:

case class Square(length: Float)
case class Circle(radius: Float)
val square = Square(5)
val circle = Circle(5)

println(square == circle) // prints false. No compilation errors

Such a comparison does not make sense as it compares two different types of objects. The program compiles successfully without any problems, but it starts causing trouble at runtime. Such cases occur mostly as a result of the programmer’s mistake.

It always gets more difficult to rectify problems at later stages as we progress with our development.

Hence, we prefer to catch errors during compile time when possible.

3. Multiversal Equality or Strict Equality

Scala developers were aware of the problems caused by the loose equality early on. Thereafter, several attempts were made to rectify this problem. The compiler gives warning messages for some unsafe equality comparisons. But this mechanism is not extensive and has multiple loopholes.

Scala 3 introduces a new feature called multiversal equality to address the problems caused by universal equality. It’s also termed strict equality. Using this new feature, we can make the equality comparisons more type-safe and, thus, reduce many unintended programming errors. By default, the strict equality check is disabled by the compiler.

We can optionally enable it by importing scala.language.strictEquality.

4. Enable Comparison Using CanEqual Instances

However, in certain cases, we may need to enable equality comparison for objects belonging to different types. In such cases, we need to create an instance of a special type-class called CanEqual. The CanEqual instance indicates to the compiler that it should explicitly allow equality comparison for those types.

There are two different ways we can create CanEqual instances.

4.1. Using Type-Class Derivation

One of the two methods – and the easiest – is to create an instance using the Type class derivation technique. The compiler automatically creates given instances in the background:

import scala.language.strictEquality    //Enables Multiversal-Equality
case class Circle(radius: Float) derives CanEqual
val circle1 = Circle(5)
val circle2 = Circle(5)
println(circle1 == circle2)    //No compilation errors & prints true.

4.2. Using the given Instance

If we need more control and flexibility, we can define our own given instances for the types we want to compare. For instance, in certain cases, we may need to compare objects of two different types.

Such instances cannot be defined using type-class derivation. Let’s look at such a scenario. Assume we have a messaging system that sends an email as well as a letter to a given recipient. The business logic may require a comparison between an email and a letter.

As a first step, let’s define a new trait called Mail containing all the generic fields:

trait Mail() {
  val fromName: String
  val toName: String
  val subject: String
  val content: String

  override def equals(that: Any): Boolean =
    that match
      case mail:Mail =>
        if this.fromName == mail.fromName
          && this.toName == mail.toName
          && this.subject == mail.subject
          && this.content == mail.content
        then true else false
      case _ =>
        false
}

Now, let’s define two case classes, one each for email and letter, and let them extend from our Mail trait:

case class Email(fromName: String, toName: String, subject: String, content: String, toEmailId:String) extends Mail
case class Letter(fromName: String, toName: String, subject: String, content: String, toAddress:String) extends Mail

Our use case requires us to compare an email object and a letter object. Since the strict-equality is enabled and the objects belong to different classes, the compiler will throw an error unless we define a CanEqual given instance for the classes.

So, let’s do that now by creating an email object and letter object and a CanEqual instance for them:

val email = Email("John", "Annie", "Hii", "How are you", "[email protected]")
val letter = Letter("John", "Annie", "Hii", "How are you", "16th Street, ParkLane, LA")

given CanEqual[Email, Letter] = CanEqual.derived

It’s now safe to invoke an equality comparison:

println(email == letter)  // Compiles successfully and prints true

5. CanEqual Default Instances

There are many default implementations available for us in the CanEqual object. They are mainly available for the primitive types in Scala. In addition to that, Number, Boolean, and Character from the java.lang package, and Seq and Set from scala.collection also have default instances.

Reflexive instances are available for all these types, meaning both the objects in the equality comparison belong to the same type. For example, a reflexive instance of Int data type would be of type CanEqual[Int, Int].

Furthermore, there are instances defined for two distinct types as well. CanEqual[Float, Double] is such an example to compare a Float value with a Double. Such instances are defined for all the combinations of numeric types, thereby helping us to compare any of them seamlessly without explicitly defining instances each time.

6. Conclusion

In this tutorial, we’ve learned the new equality constraint feature introduced in Scala 3. We’ve seen the dangers of universal equality and the errors it can cause in our programs. Then, we saw how multiversal equality solves that problem by importing the strictEquality compiler switch. Next, we moved to the CanEqual type-class instances and learned how they help us to explicitly enable comparisons between types.

Finally, we saw all the default implementations of CanEqual instances provided by the Scala 3 library.

As always, the whole code in this article is available over on GitHub.