Multiversal Equality

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI 1.17.1 on JDK 26 (checked on 2026-10-15). CanEqual and the strictEquality import exist since Scala 3.0 and behave the same on the 3.3 LTS and 3.9 LTS lines; the only difference is the SIP-67 pattern-matching relaxation, which is stable in 3.10 and experimental before (see below).

In Scala, as in Java, == is universal: it can compare a value of any type with a value of any other type and it falls back to equals. That is convenient and error-prone, because comparing an Int with a String or a TrailId with an Int is almost always a bug that the compiler could have caught. Multiversal equality is the opt-in Scala 3 answer: a type class, CanEqual[A, B], states that values of A may be compared with values of B, and a language import makes the compiler require such an instance for every == and !=. The examples use the trailhead scenario. For the equals/hashCode that a case class generates see Case Classes; for the type class mechanism that CanEqual is built on see Type Classes and Givens & Using Clauses; for deriving instances see Type Class Derivation; and for the matching side see Pattern Matching.

Universal equality and its pitfalls

Out of the box a == b calls a.equals(b) (after a null check), and for the numeric types it compares by value across widening, so 1 == 1L is true. Nothing says the two operands have anything to do with each other. Scala 3 does reject a comparison when the two types can be proved disjoint, but only then. The example below compiles, and some of its results are surprising:

//> using scala 3.10.0

package trailhead

case class Trail(name: String)
case class Hut(name: String)

@main def run(): Unit =
  val t = Trail("Ridge")
  val h = Hut("Ridge")
  println(t == h)                     // unrelated case classes: false, compiles
  println(1 == 1L)                    // numeric widening: true
  println(Option(1) == None)          // false
  val any: Any = "1"
  println(any == 1)                   // Any hides the types: false
  println(List(1, 2) == Vector(1, 2)) // different collection types: true
false
true
false
false
true

Two further results show what the compiler does and does not check. Comparing "1" with 1, or Some(1) with List(1), is refused even without any import, because a String is never an Int and a Some is never a List:

//> using scala 3.10.0

@main def run(): Unit =
  println("1" == 1)
  println(Some(1) == List(1))
[error] ./universal2.scala:4:11
[error] Values of types String and Int cannot be compared with == or !=
[error]   println("1" == 1)
[error]           ^^^^^^^^
[error] ./e1b.scala:5:11
[error] Values of types Some[Int] and List[Int] cannot be compared with == or !=
[error]   println(Some(1) == List(1))
[error]           ^^^^^^^^^^^^^^^^^^

That built-in check is a heuristic: it needs both operands to be classes it can prove unrelated. Two case classes that are not final (like Trail and Hut above), or a type hidden behind Any slip through. A wrapper type compared with the value it wraps is the classic accident, for example Option(TrailId(7)) == Some(7), which compiles and is false. Multiversal equality closes that gap by making the rule explicit instead of heuristic.

CanEqual and strictEquality

CanEqual is a type class with two type parameters and no members:

sealed trait CanEqual[-L, -R]
object CanEqual:
  object derived extends CanEqual[Any, Any]

An instance of CanEqual[L, R] is a permission slip: values of type L may be compared with values of type R. The compiler only consults it when the language import scala.language.strictEquality is in scope, or the option -language:strictEquality is passed to the compiler. Without it CanEqual is dormant and universal equality applies.

With the import every == and != needs an instance, and the error names the two types:

//> using scala 3.10.0
import scala.language.strictEquality

case class Trail(name: String)
case class Hut(name: String)

@main def run(): Unit =
  val t = Trail("Ridge")
  val h = Hut("Ridge")
  println(t == Trail("Ridge"))
  println(t == h)
[error] ./strict1.scala:10:11
[error] Values of types Trail and Trail cannot be compared with == or !=
[error]   println(t == Trail("Ridge"))
[error]           ^^^^^^^^^^^^^^^^^^^
[error] ./e2.scala:11:11
[error] Values of types Trail and Hut cannot be compared with == or !=
[error]   println(t == h)
[error]           ^^^^^^

Note that even Trail == Trail is rejected: strict equality is a whitelist, so a user-defined class compares with nothing, not even with itself, until it says so. In a project the option goes in the build, so that every file is strict:

//> using option -language:strictEquality

In sbt it is scalacOptions += "-language:strictEquality". A single file can opt in with the import instead.

Deriving CanEqual instances

There are three ways to give a class its instance. The shortest is derives CanEqual on the definition, which generates the instance in the companion with the type parameters of the class. The explicit form is a given that returns CanEqual.derived, and it is the way to permit comparisons that the type itself cannot declare, such as two different types.

//> using scala 3.10.0
import scala.language.strictEquality

case class Trail(name: String) derives CanEqual
case class Hut(name: String)
object Hut:
  given CanEqual[Hut, Hut] = CanEqual.derived

@main def run(): Unit =
  println(Trail("a") == Trail("a"))
  println(Hut("a") != Hut("b"))
  println(1 == 1L)
  println("a" == "a")
  println(List(1, 2) == Vector(1, 2))
  println(Option(1) == None)
  println(Some(1) == Some(1))
true
true
true
true
true
false
true

The instance belongs in the companion object of the type, as Hut shows, so that implicit resolution finds it without an import (see Conversions, into & Implicit Resolution). The 1 == 1L, "a" == "a", collection and Option lines work through the predefined instances described next.

Comparing two different types needs one instance per direction, because == is checked with the left operand first:

//> using scala 3.10.0
import scala.language.strictEquality

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

given CanEqual[Meters, Feet] = CanEqual.derived
given CanEqual[Feet, Meters] = CanEqual.derived

@main def run(): Unit =
  println(Meters(1) == Feet(3.28))
  println(Feet(3.28) == Meters(1))
false
false

The comparison compiles and still yields false, because the generated equals of the two case classes never treats them as equal. CanEqual is a compile-time permission, not a change of behaviour: it does not make an equals implementation correct. A custom comparison between two types needs an equals (and hashCode) that agree with the permission you grant.

A generic class derives an instance that forwards to the instance of its type argument. Box[Int] compares with Box[Int] but not with Box[String]:

//> using scala 3.10.0
import scala.language.strictEquality

case class Box[T](value: T) derives CanEqual

@main def run(): Unit =
  println(Box(1) == Box(2))
  println(Box(1) == Box("x"))
[error] ./box.scala:8:11
[error] Values of types Box[Int] and Box[String] cannot be compared with == or !=.
[error] I found:
[error]
[error]     Box.derived$CanEqual[T_$_L, T_$_R](/* missing */summon[CanEqual[T_$_L, T_$_R]])
[error]
[error] But no implicit values were found that match type CanEqual[T_$_L, T_$_R].
[error]   println(Box(1) == Box("x"))
[error]           ^^^^^^^^^^^^^^^^^^

(The first comparison, Box(1) == Box(2), is accepted; the report lists only the failing one.) Behind this is the reason CanEqual takes two type parameters, L and R, the types of the two operands, instead of one common type. == is a refinement of universal equality, whose declared form accepts any argument, and the two parameters let the compiler allow Int with Long (neither is a subtype of the other) while refusing Box[Int] with Box[String]. Because the class is contravariant in both parameters, one instance covers all subtypes of its two operand types.

Predefined instances

The compiler and the standard library provide instances so that strict equality is usable without annotating everything. The following comparisons compile under strictEquality with no instance of your own (run on 3.10.0, output below):

//> using scala 3.10.0
import scala.language.strictEquality
import scala.collection.mutable

@main def run(): Unit =
  println(true == true)
  println('a' == 97)
  println(1.5 == 1)
  println((1, "a") == (1L, "a"))
  println(Left(1) == Right("x"))
  println(mutable.ListBuffer(1) == List(1))
  println(Set(1) == Set(1))
  println(Map(1 -> "a") == Map(1 -> "a"))
  println(() == ())
  println(BigInt(1) == 1)
  println(Some(1) == None)
  println(null == "a")
  println(summon[CanEqual[Int, Long]])
  println(summon[CanEqual[List[Int], Vector[Long]]])
true
true
false
true
false
true
true
true
true
true
false
false
scala.CanEqual$derived$@31b7dea0
scala.CanEqual$derived$@31b7dea0

Seen in these results, the predefined instances cover:

  • the primitive types: the numeric types compare with each other (Int with Long or Double, Char with Int), and Boolean, String and Unit with themselves;

  • Option, Either, tuples, Set, Map and the sequence types, structurally, when their element types can be compared (List[Int] with Vector[Long] is allowed);

  • null with any reference type.

Types that are not covered stay strict. The same program also tried AnyRef with AnyRef, Array[Int] with Array[Int] and two java.time.Duration values, and each was rejected with the usual "cannot be compared with == or !=" error. A Java class needs a given CanEqual[T, T] = CanEqual.derived of its own, and Array is better compared with sameElements. (The last two summon lines print the derived instance, which has no readable form.)

Asking for an impossible one reports the same error as a failed comparison:

//> using scala 3.10.0
import scala.language.strictEquality

@main def run(): Unit =
  println(summon[CanEqual[String, Int]])
[error] ./summon.scala:4:40
[error] Values of types String and Int cannot be compared with == or !=
[error]   println(summon[CanEqual[String, Int]])
[error]                                        ^

Enums, case objects and hierarchies

An enum is the natural user of derives CanEqual, and a sealed hierarchy of case objects needs the instance on the parent type:

//> using scala 3.10.0
import scala.language.strictEquality

enum Segment derives CanEqual:
  case Path
  case Hut(beds: Int)

sealed trait Marker
case object Start extends Marker
case object Finish extends Marker
object Marker:
  given CanEqual[Marker, Marker] = CanEqual.derived

@main def run(): Unit =
  println(Segment.Path == Segment.Hut(2))
  println(Start == Finish)
false
false

The given for Marker is contravariant in both parameters (CanEqual[-L, -R]), so it also covers Start == Finish and Start == Start.

//> using scala 3.10.0
import scala.language.strictEquality

class Gate(val id: Int)

@main def run(): Unit =
  val g = Gate(1)
  println(g == g)
[error] ./gate.scala:8:11
[error] Values of types Gate and Gate cannot be compared with == or !=
[error]   println(g == g)
[error]           ^^^^^^

A plain class gets no instance. For reference identity use eq and ne, which are not affected by strictEquality.

What strictEquality does not catch

Only == and != are checked. A method that takes a supertype of the element type compares without ever asking for a CanEqual. List.contains is such a method (def contains[A1 >: A](elem: A1)), so the following compiles under strictEquality and quietly answers false:

//> using scala 3.10.0
import scala.language.strictEquality

case class TrailId(value: Int) derives CanEqual

@main def run(): Unit =
  val ids = List(TrailId(7))
  println(ids.contains(TrailId(7)))
  println(ids.contains(7))
  println(ids.exists(_ == 7))
[error] ./contains.scala:10:22
[error] Values of types TrailId and Int cannot be compared with == or !=
[error]   println(ids.exists(_ == 7))
[error]                      ^^^^^^

Removing the last line, the program compiles and prints true and false. Prefer exists(_ == x) to contains(x) in strict code, and do not read strictEquality as a guarantee that no meaningless comparison remains. The same holds for Set.contains, Map.get with a key of a different type and equals called directly.

Strict-equality pattern matching (SIP-67)

Under strictEquality a pattern that is a constant, a case object or an enum case used to be translated into ==, so it needed a CanEqual instance for the scrutinee’s type. A type that cannot derive one, for example an enum with a function parameter, could then not be matched at all. SIP-67 removes the requirement when the pattern is an object that extends the scrutinee’s type or an enum case without a parameter list. It is stable in Scala 3.10; on the 3.9 line it is available behind import scala.language.experimental.strictEqualityPatternMatching.

//> using scala 3.10.0
import scala.language.strictEquality

enum Segment:
  case Path
  case Cable(speed: Int => Int)

def effort(s: Segment): Int = s match
  case Segment.Path     => 1
  case Segment.Cable(f) => f(10)

@main def run(): Unit = println(effort(Segment.Path))
1

Compiling the same file with 3.9.0 and with 3.3.8 gives:

[error] ./m1_3.9.0.scala:9:8
[error] Values of types Segment and Segment cannot be compared with == or !=
[error]   case Segment.Path     => 1
[error]        ^^^^^^^^^^^^

On 3.9.0, adding the experimental import makes the file compile and print 1. On 3.10.0 the same import is accepted but reported as redundant, with a warning (checked with -deprecation): "`strictEqualityPatternMatching` is now standard, no language import is needed". The == operator stays strict in every version, so a == b on two Segment values still needs a CanEqual[Segment, Segment]; the strict-equality section of Pattern Matching shows that error.

Java, Kotlin and Scala 2

  • Java has only universal equality: a.equals(b) takes an Object, and a == b on references checks identity. The compiler rejects == only between provably incompatible reference types. See Java records and sealed classes for what a record generates.

  • Kotlin separates structural == (which calls equals) from identity ===. == between incompatible types is an error at compile time, which is close to the Scala built-in check but has no opt-in strict mode. See Kotlin Equality & Operator Overloading.

  • Scala 2 has universal equality only. Libraries such as Cats provide a type-class Eq[A] with a separate operator ===, which is a different mechanism: it adds a method rather than constraining ==. CanEqual is built into the language and constrains the existing ==.

Where the books are outdated

  • Material written for Scala 3.0 to 3.9 says that matching an enum case or a case object under strictEquality needs a CanEqual instance. Since 3.10 it does not (SIP-67).

  • Introductions that present strictEquality as a guarantee of comparison safety overstate it. As shown above, it checks == and != only.

Scala 3.9 LTS versus 3.10

  • CanEqual, derives CanEqual and -language:strictEquality are the same on 3.3, 3.9 and 3.10.

  • SIP-67 is stable in 3.10, experimental in 3.9 (checked with 3.9.0: needs the experimental import) and absent in 3.3 (checked with 3.3.8: rejected).