Field Notes

Cats Core, Part 7: Eq and the Comparisons == Lets Through

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
false

Nothing 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: TodoId

Eq.fromUniversalEquals builds an instance that uses == underneath. The runtime check is the same, but the compiler now checks the types.

Two-column comparison of Scala == and Cats === on four comparisons: two todos with different ids, a TodoId against a String, an Int against a String, and Some(1) against None
== gives a warning at best and a false at runtime. === makes the mixed-type comparisons fail to compile, and the Eq instance decides what counts as equal.

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
Three ways to give the compiler an Option[Int] on both sides. All three compile and print false.

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)        // true

The 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 an Eq instance, so the TodoId-against-String mistake becomes a compile error.
  • If the compiler says value === is not a member, check the inferred type. It is usually Some or None.type where an Option is needed.
  • Eq.by defines equality on chosen fields, and Cats derives it for containers. Keep such instances close to where they are used.

Keep reading