Skip to main content
Version: 2.x

Equal

Equal[A] describes the ability to compare two values of type A for equality.

Its signature is:

trait Equal[-A] {
def equal(left: A, right: A): Boolean
}

If we import zio.prelude._ we can use the === and !== operators to compare any two values of type A for equality as long as there is an Equal instance defined for them.

This has several advantages over using the == and != defined in the Scala standard library.

First, using Equal prevents bugs because it stops nonsensical code that attempts to compare unrelated types from compiling.

Using == we can compare any two values for equality, even if they are unrelated types. For example, this code has a bug.

final case class SequenceNumber(value: Int)

def isInitial(sequenceNumber: SequenceNumber): Boolean =
sequenceNumber == 0
//

We're trying to determine if this is the initial sequence number , but we're comparing the sequenceNumber, which is a SequenceNumber, with 0, which is an Int. So this comparison will always be false even if the sequenceNumber is SequenceNumber(0) which is what we are trying to test for.

This snippet may generate a warning but it is still valid Scala code.

In contrast, ZIO Prelude's Equal abstraction catches the bug and prevents this code from even compiling.

import zio.prelude._

final case class SequenceNumber(value: Int)

object SequenceNumber {
implicit val SequenceNumberEqual: Equal[SequenceNumber] =
Equal.default
}

def isInitial(sequenceNumber: SequenceNumber): Boolean =
sequenceNumber === 0
// error: No implicit Equal defined for Any.
// sequenceNumber === 0
// ^^^^^^^^^^^^^^^^^^^^

To get the code to compile we have to fix the bug.

import zio.prelude._

case class SequenceNumber(value: Int)

object SequenceNumber {

val initial: SequenceNumber =
SequenceNumber(0)

implicit val SequenceNumberEqual: Equal[SequenceNumber] =
Equal.default
}

def isFirst(sequenceNumber: SequenceNumber): Boolean =
sequenceNumber === SequenceNumber.initial

A second advantage is catching bugs when we try to compare two values of a type that doesn't have a meaningful notion of equality. For example, it doesn't make sense to compare two Scala functions for equality, but we can still do it with ==.

In contrast, comparing two functions with === will result in a compilation error, preventing us from making this mistake.

A third advantage of the Equal abstraction is that it lets us use our own notion of equality for data types that are not under our control. We may be working with data types from other libraries that do not implement a reasonable notion of equality, for example using reference equality instead of value equality.

If we are using == we can't do anything about that because == is always based on how the data type implements equals. But if we are using === from ZIO Prelude we can define our own Equal instance that implements a more sensible notion of equality.

Defining Equal Instances

Equal instances are automatically available for data types from the Scala library and ZIO with a meaningful notion of equality, as well as data types made up of those types.

We can always check if an instance of a type class exists for a given data type by using the apply operator to tell the Scala compiler to find the appropriate instance for us. This will result in a compilation error if the instance cannot be found.

Equal[List[Either[String, Int]]]
// res3: Equal[List[Either[String, Int]]] = zio.prelude.Equal$$anonfun$make$2@23575bf2
Equal[String => Int]

Defining Equal instances for your own data types is generally quite easy.

If you have already defined a sensible equals method for your data type then you can just use the default operator to construct an Equal instance as we saw in the example above involving the SequenceNumber.

Since the SequenceNumber is a case class it already has a well defined equals method as long as each value in the case class also has a well defined equals method. Equality for Int values is definitely well behaved so there is nothing we have to do here other than use the default operator.

If we don't want to use the default definition of equality we can use the make operator to define a new Equal instance based on our own definition of equality.

This is particularly useful if we are defining an Equal instance for a parameterized type and want to compare the types it is parameterized on for equality using their own Equal instances.

For example, say we have a class class to represent a pair of values.

case class Pair[A, B](first: A, second: B)

The equals method on a case class delegates to the equals method on each of its fields and we don't know if A or B have sensible definitions of equals since they are generic. So we want to instead define our own Equal instance directly like this:

object Pair {
implicit def PairEqual[A: Equal, B: Equal]: Equal[Pair[A, B]] =
Equal.make { (left, right) =>
left.first === right.first && left.second === right.second
}
}

Notice how we had to require that Equal instances existed for A and B. We can't just compare any Pair for equality, we can only do it if each field of the pair can also be compared for equality.

Including Equal after the definition of the A and B type parameters here is called a context bound_ and is another way of saying that an implicit instance of Equal[A] and Equal[B] must exist. It can make our code a little cleaner when we don't need to do anything with the instance other than require that it exists, in this case for the === operator.

If we can convert our data type to another data type for which an Equal instance is already defined we can make this even simpler using the contramap operator.

In the example above, we can convert any Pair into a tuple without losing any information. And ZIO Prelude already defines an Equal instance for any tuple of data types for which Equal instances are defined.

So we can rewrite our implementation of PairEqual like this:

object Pair {
implicit def PairEqual[A: Equal, B: Equal]: Equal[Pair[A, B]] =
Equal[(A, B)].contramap(pair => (pair.first, pair.second))
}

The contramap operator essentially says if we can compare two tuples for equality, and we can turn a pair into a tuple, then we can always compare two pairs for equality by turning them both into tuples and comparing the tuples for equality.

The contramap operator can be useful to create Equal instances from existing ones but the way we "work backwards" from the type we know how to compare for equality to the Equal instance we are trying to make can be confusing initially. So it is fine to just use the make operator as discussed above, which essentially does the same thing.

With the instances already defined for all types in ZIO and the Scala standard library with meaningful definitions of equality and the ability to define instances for your own data types with make and contramap it is easy to take advantage of the additional type safety that using === provides.