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 (
IntwithLongorDouble,CharwithInt), andBoolean,StringandUnitwith themselves; -
Option,Either, tuples,Set,Mapand the sequence types, structurally, when their element types can be compared (List[Int]withVector[Long]is allowed); -
nullwith 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 anObject, anda == bon 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 callsequals) 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==.CanEqualis 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
strictEqualityneeds aCanEqualinstance. Since 3.10 it does not (SIP-67). -
Introductions that present
strictEqualityas a guarantee of comparison safety overstate it. As shown above, it checks==and!=only.