Type safe equality for Scala 3
This library aims to prevent compilation of code comparing arbitrary values unless equality is specifically supported for their respective types.
- Any operations adhering to the principle above are considered to be equality-safe.
- A
Product
type (case class, enum, tuple) can support equality only if the types of all its fields support equality.
This makes equality behave similarly to Data.Eq in Haskell or std::cmp::Eq in Rust.
The following features are provided leveraging the compiler strict equality support:
- Eq type class - value equality type class with automatic derivation for
Product
types - EqRef type class - reference equality type class with automatic derivation for non-extensible types
- Standard equality instances - equality support for relevant Java and Scala standard library types
- Collection extensions - equality-safe extension methods for standard Scala collections
- Hybrid equality - combined use of equality constructs for both value and reference equality
- Universal equality - escape hatch to enable default Scala behavior concerning equality
Please see feature selection and FAQ for additional information.
Execute the following commands:
sbt new antognini/type-safe-equality.g8
cd type-safe-equality-example
sbt console
Run any of the examples shown below by copy & pasting them into REPL.
This library requires Scala 3.3+ on Java 11+.
Include the library dependency in your build.sbt
and enable strict equality:
libraryDependencies += "ch.produs" %% "type-safe-equality" % "0.6.0"
scalacOptions += "-language:strictEquality"
scalacOptions += "-Yimports:scala,scala.Predef,java.lang,equality"
Try it out:
import java.time.LocalDateTime
import java.util.jar.Attributes
// Use equality for a standard library type out of the box
val now = LocalDateTime.now
now == now
// Explicitly assume equality for an arbitrary type
given Eq[Attributes] = Eq.assumed
Attributes() == Attributes()
// Derive equality for a product type
case class Box[T: Eq](
name: String,
item: Either[String, T],
) derives Eq
val box = Box("", Right(Attributes()))
box == box
// Use an equality-safe alternative to .contains()
val names = List(now, now)
names.contains_eq(now)
All features described below assume that strict equality compiler flag is enabled.
Eq
is a type class providing type safe use of ==
and !=
as value equality operators and
automatic verification of value equality support for product types.
Eq instances for an arbitrary type T
can be obtained in one of the following ways:
-
By asking the compiler to verify (derive) value equality support
<type T> derives Eq
given Eq[T] = Eq.derived
-
By telling the compiler to assume value equality support (fallback with no checking)
<type T> derives Eq.assumed
given Eq[T] = Eq.assumed
Note: It is recommended to assume value equality support only if it is not possible to verify it.
Eq limits the ==
and !=
operators to compare values only and
only if given Eq instance is in scope for the compared type.
This causes the following differences from the default Scala behavior:
- It completely disallows comparison of values for unrelated or unsupported types.
- It practically disables the use
==
and!=
operators for comparing references.
Verify equality for composed case classes via type class derivation:
case class Email(address: String) derives Eq
// Compiles because Email derives Eq
case class Person(
name: String,
contact: Email,
) derives Eq
val person = Person("Alice", Email("[email protected]"))
// Compiles because Person derives Eq
person == person
Verify equality for composed case classes with type parameters via type class derivation:
case class Email(address: String) derives Eq
// Compiles because the type parameter T is declared with a context bound [T: Eq]
case class Person[T: Eq](
name: String,
contact: T,
) derives Eq
// Compiles because Email derives Eq
val person = Person("Alice", Email("[email protected]"))
// Compiles because Person derives Eq
person == person
Verify equality for an enum with a given using the same derivation mechanism:
enum Weekday:
case Monday, Tuesday, Wednesday // ...
// Compiles because Weekday is a product type with all options supporting equality
given Eq[Weekday] = Eq.derived
// Compiles because given Eq type class instance for Weekday is in scope
Weekday.Monday == Weekday.Monday
Assume equality for the bottom classes of a class hierarchy via type class derivation:
class Animal
case class Cat() extends Animal derives Eq.assumed
case class Dog() extends Animal
val cat = Cat()
val dog = Dog()
// Compiles because the selected bottom class of this hierarchy derives Eq
cat == cat
dog == dog
// ERROR: Values of types Dog and Dog cannot be compared with == or !=
cat != dog
// ERROR: Values of types Cat and Dog cannot be compared with == or !=
Assume equality for the base class of a class hierarchy via type class derivation:
class Animal derives Eq.assumed
case class Cat() extends Animal
case class Dog() extends Animal
val cat = Cat()
val dog = Dog()
// Compiles because the base class of this hierarchy derives Eq
cat == cat
dog == dog
cat != dog
Assume equality for an existing arbitrary type with a given:
import java.util.jar.Attributes
given Eq[Attributes] = Eq.assumed
val attributes = Attributes()
// Compiles because given Eq type class instance for Attributes is in scope
attributes == attributes
In order to successfully verify (derive) equality for a type T
all following conditions must be satisfied:
- Type
T
is aProduct
- For each field of type
F
:- A given instance of
Eq[F]
is available in the current scope - For each type parameter
P
used withinF
:P
is declared with a context bound[P: Eq]
- A given instance of
Equality verification rules examples:
// Rule 1: a case class is a Product
case class MyClass[A: Eq, B: Eq, C: Eq, D: Eq](
// Rule 2.i given instance of Eq[OtherClass] is available in the current scope
other: OtherClass,
// Rule 2.i given instance of Eq[?] is available in the current scope
// Rule 2.ii.a A is declared with a context bound [A: Eq]
a: A,
// Rule 2.i given instance of Eq[Box[?]] is available in the current scope
// Rule 2.ii.a B is declared with a context bound [B: Eq]
box: Box[B],
// Rule 2.i given instance of Eq[Pair[?, Seq[?]]] is available in the current scope
// Rule 2.ii.a C is declared with a context bound [C: Eq]
// Rule 2.ii.a D is declared with a context bound [D: Eq]
pair: Pair[C, Seq[D]]
) derives Eq
EqRef
is a type class providing type safe use of eqRef
and neRef
as reference equality operators and
automatic verification of reference equality support for non-extensible types.
These provide substitutes for the built-in eq
and ne
operators which are not type safe.
EqRef instances for an arbitrary type T
can be obtained in one of the following ways:
-
By asking the compiler to verify (derive) reference equality support [ currently implemented but not verifying ]
<type T> derives EqRef
given EqRef[T] = EqRef.derived
-
By telling the compiler to assume reference equality support (fallback with no checking)
<type T> derives EqRef.assumed
given EqRef[T] = EqRef.assumed
Note: It is recommended to assume reference equality support only if it is not possible to verify it.
EqRef introduces eqRef
and neRef
methods which compare references only and
only if given EqRef instance is in scope for the compared type.
This causes the following differences from the default Scala behavior:
- It completely disallows comparison of references for unrelated or unsupported types.
- It requires the use of
eqRef
andneRef
operators in order to compare references.
- It requires the use of
Verify equality for a non-extensible class via type class derivation:
// Compiles because Item cannot be extended and does not override the equals() method
class Item(val id: String) derives EqRef
val item = Item("")
// Compiles because given EqRef type class instance for Item is in scope
item eqRef item
Verify equality for a non-extensible class with a given using the same derivation mechanism:
class Item(id: String)
// Compiles because Item is cannot be extended and overrides the equals() method
given EqRef[Item] = EqRef.derived
val item = Item("")
// Compiles because given EqRef type class instance for Item is in scope
item neRef item
Assume equality for an arbitrary class via type class derivation:
class Item(val id: String) derives EqRef.assumed
val item = Item("")
// Compiles because Item derives EqRef
item eqRef item
Assume equality for an arbitrary class with a given using the same derivation mechanism:
class Item(val id: String)
given EqRef[Item] = EqRef.assumed
val item = Item("")
// Compiles because given EqRef type class instance for Item is in scope
item neRef item
This library provides Eq and EqRef type class instances for various commonly used Scala and Java standard library types.
These type class instances exists only for types where value or reference equality makes sense and provide type safe equality at no cost.
Use standard value equality type class instance to compare standard temporal values:
import java.time.{LocalDate, LocalDateTime}
val now = LocalDateTime.now
val later = LocalDateTime.now
// Compiles because given Eq type class instance for LocalDateTime is in scope
now == later
val today = LocalDate.now
today != now
// ERROR: Values of types LocalDate and LocalDateTime cannot be compared with == or !=
Certain methods of specific standard library collection types (and their subtypes) are not equality-safe.
This library provides the following equality-safe alternatives for such methods:
Collection types | Original method | Equality-safe method |
---|---|---|
Seq , Iterator | .contains |
.contains_eq |
Seq | .containsSlice |
.containsSlice_eq |
Seq | .diff |
.diff_eq |
Seq , Iterator | .indexOf |
.indexOf_eq |
Seq | .indexOfSlice |
.indexOfSlice_eq |
Seq | .intersect |
.intersect_eq |
Seq | .lastIndexOf |
.lastIndexOf_eq |
Seq | .lastIndexOfSlice |
.lastIndexOfSlice_eq |
Seq , Iterator, IterableOnce | .sameElements |
.sameElements_eq |
Seq | .search |
.search_eq |
Note: Set and Map are generally equality-safe because they use invariant type parameters.
Using equality-safe collection methods:
case class Apple(x: String) derives Eq
val appleA = Apple("A")
val appleB = Apple("B")
val apples = List(appleA, appleB)
case class Car(x: String) derives Eq
val carY = Car("Y")
val carX = Car("X")
val cars = List(carX, carY)
// Compiles but it should not since it is meaningless and always returns false
apples.contains(carX)
apples.contains_eq(carX)
// ERROR: Values of types A and A cannot be compared with == or !=
// where: A is a type variable with constraint >: Apple | Car
// Compiles but it should not since it is meaningless and always returns the original list
apples.diff(cars)
apples.diff_eq(cars)
// ERROR: Values of types Apple and Apple | Car cannot be compared with == or !=
Hybrid equality allows the use of value equality constructs also for reference equality.
Enabling the hybrid equality has the following effects:
==
and!=
operators can also compare references if given EqRef instance is in scope for the compared type.- Eq derivation mechanism for product types supports fields with EqRef instances.
Note: Mixing value and reference equality is generally discouraged and should be limited to special cases which would cause difficulties otherwise.
Hybrid equality for a specific scope can be enabled as follows:
import equality.hybrid.given
Using hybrid equality:
class Item(val id: String) derives EqRef.assumed
val item = Item("")
// Compiles because hybrid equality is enabled
item == item
Universal equality allows comparison of unrelated types which is the default Scala behavior.
Universal equality for a specific scope can be enabled as follows:
import equality.universal.given
Using universal equality:
// Compiles because universal equality is enabled
1 == true
Multiple ways how to gradually enable various features of this library are described below.
Build:
scalacOptions += "-Yimports:scala,scala.Predef,java.lang,equality"
Build with hybrid equality:
scalacOptions += "-Yimports:scala,scala.Predef,java.lang,equality,equality.hybrid"
Import:
import equality.{*, given}
Import with hybrid equality:
import equality.{*, given}
import equality.hybrid.given
Import without standard Eq and EqRef instances:
import equality.core.{*, given}
Import with specific standard Eq and EqRef instances and collection extensions only:
import equality.core.{*, given}
// Eq type class instances for types in package scala
import equality.scala_.{*, given}
// Eq type class instances for types in package java.time
import equality.java_time.{*, given}
// Eq type class instance for java.text.Format
import equality.java_text.java_text_Format
// EqRef type class instance for java.net.Socket
import equality.java_net.java_net_Socket
// Equality-safe collection extension
import equality.scala_collection.CollectionExtension.*
Each instance of Eq
type class produces an instance of CanEqual
thus making it compatible with the established strict equality support in the Scala compiler.
CanEqual
is a fairly low-level marker mechanism with following limitations:
CanEqual
does not support compile-time verification of equality safety for product types composed of other equality-safe types.CanEqual
given instances are automatically provided for a few basic standard library types but for nothing else.
Composition example with CanEqual
not failing as it should:
case class A()
// Compiles but should not compile since member type A neither derives CanEqual nor is there a given CanEqual instance for it
case class B(a: A) derives CanEqual
B(A()) == B(A())
See the motivation for CanEqual[-L, -R]
in the documentation about Scala 3 equality.
This library focuses on strict equality and therefore the Eq
type class can be expressed with a single type parameter.
Eq[-T]
reflects the principle by which given Eq[A]
allows any equality comparison between values of type A
or any type more specialized than A
.
- For type
B
which extends typeA
,given Eq[A]
allows pairwise comparison between values ofA
orB
. - For unrelated types
A
andB
,given Eq[A | B]
allows pairwise comparison between values ofA
,B
orA | B
. - For unrelated types
A
andB
,given Eq[A]
allows pairwise comparison between values ofA
, orA & B
.
No, they don't because they would require for the type Any
to support equality, which would defeat the purpose.
It does not seem to be possible to create given Eq instances for enum types automatically in Scala 3 without using a compiler plugin.
Why does comparing two instances of the same class using different numeric type parameters not compile ?
This library follows a principle that values of different types are not comparable. This includes numeric types so this is a feature and not a bug.
Contrary to this principle, the compiler makes values of numeric types universally comparable even with strict equality enabled.
If universal equality for case classes parameterized with different numeric types is required, the following method can be used:
case class Box[T: Eq](value: T) derives Eq, CanEqual
// Compiles because Box also derives CanEqual
Box(1) == Box(1L)
// Call this function anywhere in your sources
checkStrictEqualityBuild()
Special thanks to
- Martin Ockajak