Cats Core, Part 7: Eq and the Comparisons == Lets Through
Parts 1 to 6 were about combining values: two optional fields, two error reports, a cart of line totals. This part is about comparing two values. Cats has a type class for that too: Eq. My first reaction was that Scala already has ==, so why would I want another one?
I worked it out in the same Play 3 project as before (Scala 2.13.18, cats-core 2.13.0), starting from a scratch file, app/services/Tests.scala.
The file I started with
package services
import cats.Eq
import cats.implicits.{catsSyntaxEq, catsSyntaxOptionId, none}
import cats.instances.option._
object Tests extends App {
val eqInt = Eq[Int]
println(eqInt.eqv(123, 123))
println(eqInt.eqv(123, 124))
println(123 === 123)
println(123 =!= 234)
private val bool: Boolean = (Some(1): Option[Int]) === (None: Option[Int])
println(bool)
println(1.some === none[Int])
}Running it prints:
true
false
true
true
false
falseNothing surprising in the output. The interesting parts are the two lines with type ascriptions and 1.some, and I explain those further down.
What Eq is
Eq lives in cats-kernel and has one abstract method:
trait Eq[A] {
def eqv(x: A, y: A): Boolean
}Both parameters have the same type A. That is the whole difference from Scala’s ==, which comes from Any and delegates to equals(other: Any). Any value can be compared with any other value, and the result is decided at runtime.
=== and =!= are syntax over eqv. With import cats.syntax.all._, a === b compiles only if there is an Eq[A] for the type of a and b also has type A.
What == lets through
To see the difference I wrote a small experiment. In the todo app from Part 6, ids are wrapped in a TodoId case class, and filtering by a raw string is an easy mistake to make. It still compiles:
final case class TodoId(value: String)
final case class Todo(id: TodoId, title: String, done: Boolean)
val todos = List(Todo(TodoId("a1"), "buy milk", done = false))
val wanted: String = "a1"
todos.filter(_.id == wanted) // List()To be fair to Scala 2.13, the compiler does notice: warning: comparing values of types TodoId and String using `==` will always yield false. But it is still a warning, my build does not set -Xfatal-warnings, and a warning scrolling past in a Play dev server is easy to miss. The code runs and quietly returns an empty list.
With an Eq[TodoId] in scope, the same line written with === does not compile:
implicit val todoIdEq: Eq[TodoId] = Eq.fromUniversalEquals
todos.filter(_.id === wanted)
// error: type mismatch;
// found : String
// required: TodoIdEq.fromUniversalEquals builds an instance that uses == underneath. The runtime check is the same, but the compiler now checks the types.

The Some and None trap
My first attempt at the Option line was Some(1) === None. It fails, and the error message is not obviously about Eq:
error: value === is not a member of Some[Int]Cats provides Eq[Option[A]], not Eq[Some[A]]. Eq is invariant, so an Eq[Option[Int]] cannot be used as an Eq[Some[Int]]. The === syntax is added by an implicit conversion that needs an Eq for the type on the left. The compiler infers Some[Int], finds no instance, does not apply the conversion, and reports that the method does not exist.
The fix is to make both sides Option[Int]. That is what the type ascriptions in my file do, and why Cats has 1.some and none[Int]: their declared type is already Option.
![Code: Some(1) === None fails with value === is not a member of Some[Int]; three forms that widen both sides to Option[Int] all compile and return false](/blog/media/6b2cba6a278473b89df8b94907c2b499d66f6ae124bf14d0dc35c94585452bb2.png)
Two imports I did not need
My file imports cats.instances.option._. Since Cats 2.2.0 the standard instances are in implicit scope, so the compiler finds Eq[Option[A]] without that import. My second scratch file compares Option and List values with only import cats.syntax.all._ and compiles fine.
The named import from cats.implicits works, but cats.syntax.all._ is the shorter form I use everywhere else in this series. It brings ===, .some and none in one line.
Deciding what equal means
Eq also lets me choose which fields count. Two todos with the same title and status but different ids are different according to ==, because case-class equality compares every field. If what I care about is the content, Eq.by compares through a projection:
implicit val todoContentEq: Eq[Todo] = Eq.by(t => (t.title, t.done))
val a = Todo(TodoId("a1"), "buy milk", done = false)
val b = Todo(TodoId("b7"), "buy milk", done = false)
a == b // false
a === b // true
a === b.copy(done = true) // false
List(a) === List(b) // true
Option(a) === Option(b) // trueThe last two lines show the type class doing the work. I wrote one instance for Todo, and Cats derived Eq[List[Todo]] and Eq[Option[Todo]] from it. It is the same pattern as Semigroup in Part 2 and Functor in Part 4: provide one instance, and the container instances build on it.
There is a trade-off. After this, a == b and a === b give different answers for the same two values. That is useful in a test that ignores ids, but confusing as an implicit that is always in scope. I would keep an instance like todoContentEq local to the code that needs it, and not put it in the Todo companion object.
What I take away
==accepts any two values. Scala 2.13 warns about some obviously wrong comparisons, but they still compile.===requires both sides to have the same type and anEqinstance, so theTodoId-against-Stringmistake becomes a compile error.- If the compiler says
value === is not a member, check the inferred type. It is usuallySomeorNone.typewhere anOptionis needed. Eq.bydefines equality on chosen fields, and Cats derives it for containers. Keep such instances close to where they are used.