Scala 3 — Book

Multiversal Equality

Language
This doc page is specific to Scala 3, and may cover new concepts not available in Scala 2. Unless otherwise stated, all the code examples in this page assume you are using Scala 3.

Previously, Scala had universal equality: Two values of any types could be compared with each other using == and !=. This came from the fact that == and != are implemented in terms of Java’s equals method, which can also compare values of any two reference types.

Universal equality is convenient, but it’s also dangerous since it undermines type safety. For instance, let’s assume that after some refactoring, you’re left with an erroneous program where a value y has type S instead of the correct type T:

val x = ...   // of type T
val y = ...   // of type S, but should be T
x == y        // typechecks, will always yield false

If y gets compared to other values of type T, the program will still typecheck, since values of all types can be compared with each other. But it will probably give unexpected results and fail at runtime.

A type-safe programming language can do better, and multiversal equality is an opt-in way to make universal equality safer. It uses the binary type class CanEqual to indicate that values of two given types can be compared with each other.

Allowing the comparison of class instances

By default, in Scala 3 you can still create an equality comparison like this:

case class Cat(name: String)
case class Dog(name: String)
val d = Dog("Fido")
val c = Cat("Morris")

d == c  // false, but it compiles

But with Scala 3 you can disable such comparisons. By (a) importing scala.language.strictEquality or (b) using the -language:strictEquality compiler flag, this comparison no longer compiles:

import scala.language.strictEquality

val rover = Dog("Rover")
val fido = Dog("Fido")
println(rover == fido)   // compiler error

// compiler error message:
// Values of types Dog and Dog cannot be compared with == or !=

Enabling comparisons

There are two ways to enable this comparison using the Scala 3 CanEqual type class. For simple cases like this, your class can derive the CanEqual class:

// Option 1
case class Dog(name: String) derives CanEqual

As you’ll see in a few moments, when you need more flexibility you can also use this syntax:

// Option 2
case class Dog(name: String)
given CanEqual[Dog, Dog] = CanEqual.derived

Either of those two approaches now let Dog instances to be compared to each other.

A more real-world example

In a more real-world example, imagine you have an online bookstore and want to allow or disallow the comparison of physical, printed books, and audiobooks. With Scala 3 you start by enabling multiversal equality as shown in the previous example:

// [1] add this import, or this command line flag: -language:strictEquality
import scala.language.strictEquality

Then create your domain objects as usual:

// [2] create your class hierarchy
trait Book:
    def author: String
    def title: String
    def year: Int

case class PrintedBook(
    author: String,
    title: String,
    year: Int,
    pages: Int
) extends Book

case class AudioBook(
    author: String,
    title: String,
    year: Int,
    lengthInMinutes: Int
) extends Book

Finally, use CanEqual to define which comparisons you want to allow:

// [3] create type class instances to define the allowed comparisons.
//     allow `PrintedBook == PrintedBook`
//     allow `AudioBook == AudioBook`
given CanEqual[PrintedBook, PrintedBook] = CanEqual.derived
given CanEqual[AudioBook, AudioBook] = CanEqual.derived

// [4a] comparing two printed books works as desired
val p1 = PrintedBook("1984", "George Orwell", 1961, 328)
val p2 = PrintedBook("1984", "George Orwell", 1961, 328)
println(p1 == p2)         // true

// [4b] you can’t compare a printed book and an audiobook
val pBook = PrintedBook("1984", "George Orwell", 1961, 328)
val aBook = AudioBook("1984", "George Orwell", 2006, 682)
println(pBook == aBook)   // compiler error

The last line of code results in this compiler error message:

Values of types PrintedBook and AudioBook cannot be compared with == or !=

This is how multiversal equality catches illegal type comparisons at compile time.

Enabling “PrintedBook == AudioBook”

That works as desired, but in some situations you may want to allow the comparison of physical books to audiobooks. When you want this, create these two additional equality comparisons:

// allow `PrintedBook == AudioBook`, and `AudioBook == PrintedBook`
given CanEqual[PrintedBook, AudioBook] = CanEqual.derived
given CanEqual[AudioBook, PrintedBook] = CanEqual.derived

Now you can compare physical books to audiobooks without a compiler error:

println(pBook == aBook)   // false
println(aBook == pBook)   // false

Implement “equals” to make them really work

While these comparisons are now allowed, they will always be false because their equals methods don’t know how to make these comparisons. Therefore, the solution is to override the equals methods for each class. For instance, when you override the equals method for AudioBook:

case class AudioBook(
    author: String,
    title: String,
    year: Int,
    lengthInMinutes: Int
) extends Book:
    // override to allow AudioBook to be compared to PrintedBook
    override def equals(that: Any): Boolean = that match
        case a: AudioBook =>
            this.author == a.author
            && this.title == a.title
            && this.year == a.year
            && this.lengthInMinutes == a.lengthInMinutes
        case p: PrintedBook =>
            this.author == p.author && this.title == p.title
        case _ =>
            false

You can now compare an AudioBook to a PrintedBook:

println(aBook == pBook)   // true (works because of `equals` in `AudioBook`)
println(pBook == aBook)   // false

Currently, the PrintedBook book doesn’t have an equals method, so the second comparison returns false. To enable that comparison, just override the equals method in PrintedBook.

You can find additional information on multiversal equality in the reference documentation.

Contributors to this page: