Skip to Content
Course content

176: Multiversal Equality in Scala 3

Click on the "Edit" button in the top corner of the screen to edit your slide content.

In Scala 2, equality was always a bit of a minefield. You had ==, which calls equals, but then you had the whole mess of Any, AnyRef, and AnyVal. If you were writing generic code, you often found yourself fighting the compiler or, worse, getting runtime results that didn't make sense because of type erasure. Scala 3 introduces "Multiversal Equality," which is a fancy way of saying the language has fundamentally changed how it handles == to be more consistent across different type "universes" (like when you're dealing with a specific type Int versus a general type Any).

Wrapping our identifiers

Let's build something concrete. Imagine we're building a system for a library, and we want to make sure we don't accidentally mix up a BookId with a MemberId, even though both are just strings under the hood. I'll start by creating a simple generic wrapper.

case class Identifier[T](value: T)

This looks innocent enough. I can now do Identifier("123") for a book and Identifier("456") for a member. Because it's a case class, Scala gives us equals and hashCode for free. If I compare two Identifier[String] objects, it works exactly as you'd expect.

The problem with generic comparisons

Here is where things get interesting. In a real app, you often have a "Search" service that doesn't know exactly what kind of identifier it's dealing with—it just knows it's some kind of Identifier[Any]. I want to write a method that checks if a provided identifier matches a target value.

def isMatch(id: Identifier[Any], target: Any): Boolean = {
  id.value == target
}

In Scala 2, this was a spot where you'd start worrying about whether target was being boxed, whether the types were erased, and whether == would actually trigger the correct equals method. In Scala 3, the "multiversal" nature of equality means the compiler handles the transition between the specific type T and the universal type Any much more intelligently. It ensures that == behaves consistently regardless of whether the types are known at compile-time or only at runtime.

My attempt to force the type

Now, I'll make a mistake. I remember from my old Scala 2 days that relying on Any can be dangerous, so I'll try to "help" the compiler by overriding the equality logic to be more explicit. I'll try to implement a custom equality check that ensures the types match before comparing values.

case class Identifier[T](value: T) {
  override def equals(obj: Any): Boolean = obj match {
    case that: Identifier[T] => this.value == that.value
    case _ => false
  }
}

I thought I was being safe here, but I actually just broke the code. Because of type erasure, Identifier[T] at runtime is just Identifier. My pattern match case that: Identifier[T] doesn't actually check if T is the same; it just checks if it's any Identifier. If I compare an Identifier[Int](1) with an Identifier[String]("1"), this code might actually try to compare an Int to a String using ==, which will return false, but it's doing so in a way that's fragile and bypasses the very protections Scala 3's multiversal equality provides.

Letting the multiversal equality do the heavy lifting

The realization here is that in Scala 3, the == operator is no longer just a simple call to .equals(). It's a sophisticated operation that considers the "universe" of the types involved. When you use == on Any, Scala 3 uses a strategy that avoids the pitfalls of the old AnyRef/AnyVal split.

The fix? Delete my manual equals override and trust the case class implementation. Let's see it in action:

case class Identifier[T](value: T)

val bookId = Identifier("B123")
val memberId = Identifier("M456")
val genericId: Identifier[Any] = bookId

// This works perfectly in Scala 3
println(genericId == bookId) // true
println(genericId == memberId) // false

By removing the manual cast, I'm letting the Scala 3 compiler use its multiversal equality logic. It knows how to handle the comparison between Identifier[Any] and Identifier[String] without me having to fight the type system. I'm essentially moving from "I need to tell the compiler how to compare these" to "the compiler knows how to compare these because it understands the relationship between the types."




📋 Practical Task

Implement a Multiversal Product Key Validator

You are building a licensing system. You have a generic LicenseKey[T] case class. You need to implement a function validateKey that compares a generic LicenseKey[Any] against a known LicenseKey[String].

Requirements:

  • Create a case class LicenseKey[T](key: T).
  • Write a function validateKey(input: LicenseKey[Any], expected: LicenseKey[String]): Boolean.
  • The function must return true if the keys are equal and false otherwise.
  • Constraint: Do not override the equals method in the case class; rely on Scala 3's built-in multiversal equality to handle the comparison between Any and String.

Test your code with these cases:

  • validateKey(LicenseKey("ABC-123"), LicenseKey("ABC-123")) should be true.
  • validateKey(LicenseKey(12345), LicenseKey("ABC-123")) should be false.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.