Equal

On this page

The Equal module provides a simple and convenient way to define and check for equality between two values in TypeScript.

Here are some key reasons why Effect exports an Equal module:

  1. Value-Based Equality: JavaScript's native equality operators (=== and ==) check for equality by reference, meaning they compare objects based on their memory addresses rather than their content. This behavior can be problematic when you want to compare objects with the same values but different references. The Equal module offers a solution by allowing developers to define custom equality checks based on the values of objects.

  2. Custom Equality: The Equal module enables developers to implement custom equality checks for their data types and classes. This is crucial when you have specific requirements for determining when two objects should be considered equal. By implementing the Equal interface, developers can define their own equality logic.

  3. Data Integrity: In some applications, maintaining data integrity is crucial. The ability to perform value-based equality checks ensures that identical data is not duplicated within collections like sets or maps. This can lead to more efficient memory usage and more predictable behavior.

  4. Predictable Behavior: The Equal module promotes more predictable behavior when comparing objects. By explicitly defining equality criteria, developers can avoid unexpected results that may occur with JavaScript's default reference-based equality checks.

How to Perform Equality Checking in Effect

In Effect it's advisable to stop using JavaScript's === and == operators and instead rely on the Equal.equals function. This function can work with any data type that implements the Equal trait. Some examples of such data types include Option, Either, HashSet, and HashMap.

When you use Equal.equals and your objects do not implement the Equal trait, it defaults to using the === operator for object comparison:

ts
import { Equal } from "effect"
 
const a = { name: "Alice", age: 30 }
const b = { name: "Alice", age: 30 }
 
console.log(Equal.equals(a, b)) // Output: false
ts
import { Equal } from "effect"
 
const a = { name: "Alice", age: 30 }
const b = { name: "Alice", age: 30 }
 
console.log(Equal.equals(a, b)) // Output: false

In this example, a and b are two separate objects with the same contents. However, === considers them different because they occupy different memory locations. This behavior can lead to unexpected results when you want to compare values based on their content.

However, you can configure your models to ensure that Equal.equals behaves consistently with your custom equality checks. There are two alternative approaches:

  1. Implementing the Equal Interface: This method is useful when you need to define your custom equality check.

  2. Using the Data Module: For simple value equality, the Data module provides a more straightforward solution by automatically generating default implementations for Equal.

Let's delve into both solutions.

Implementing the Equal Interface

To create custom equality behavior, you can implement the Equal interface in your models. This interface extends the Hash interface from the Hash module.

Here's an example of implementing the Equal interface for a Person class:

Person.ts
ts
import { Equal, Hash } from "effect"
 
export class Person implements Equal.Equal {
constructor(
readonly id: number, // Unique identifier for each person
readonly name: string,
readonly age: number
) {}
 
// Defines equality based on id, name, and age
[Equal.symbol](that: Equal.Equal): boolean {
if (that instanceof Person) {
return (
Equal.equals(this.id, that.id) &&
Equal.equals(this.name, that.name) &&
Equal.equals(this.age, that.age)
)
}
return false
}
 
// Generates a hash code based primarily on the unique id
[Hash.symbol](): number {
return Hash.hash(this.id)
}
}
Person.ts
ts
import { Equal, Hash } from "effect"
 
export class Person implements Equal.Equal {
constructor(
readonly id: number, // Unique identifier for each person
readonly name: string,
readonly age: number
) {}
 
// Defines equality based on id, name, and age
[Equal.symbol](that: Equal.Equal): boolean {
if (that instanceof Person) {
return (
Equal.equals(this.id, that.id) &&
Equal.equals(this.name, that.name) &&
Equal.equals(this.age, that.age)
)
}
return false
}
 
// Generates a hash code based primarily on the unique id
[Hash.symbol](): number {
return Hash.hash(this.id)
}
}

In the above code, we define a custom equality function [Equal.symbol] and a hash function [Hash.symbol] for the Person class. The Hash interface optimizes equality checks by comparing hash values instead of the objects themselves. When you use the Equal.equals function to compare two objects, it first checks if their hash values are equal. If not, it quickly determines that the objects are not equal, avoiding the need for a detailed property-by-property comparison.

Once you've implemented the Equal interface, you can utilize the Equal.equals function to check for equality using your custom logic. Here's an example using the Person class:

ts
import { Equal } from "effect"
import { Person } from "./Person"
 
const alice = new Person(1, "Alice", 30)
console.log(Equal.equals(alice, new Person(1, "Alice", 30))) // Output: true
 
const bob = new Person(2, "Bob", 40)
console.log(Equal.equals(alice, bob)) // Output: false
ts
import { Equal } from "effect"
import { Person } from "./Person"
 
const alice = new Person(1, "Alice", 30)
console.log(Equal.equals(alice, new Person(1, "Alice", 30))) // Output: true
 
const bob = new Person(2, "Bob", 40)
console.log(Equal.equals(alice, bob)) // Output: false

In this code, the equality check returns true when comparing alice to a new Person object with identical property values and false when comparing alice to bob due to their differing property values.

Simplifying Equality with the Data Module

Implementing both Equal and Hash can become cumbersome when all you need is straightforward value equality checks. Luckily, the Data module provides a simpler solution. It offers APIs that automatically generate default implementations for both Equal and Hash.

Let's see how it works:

ts
import { Equal, Data } from "effect"
 
const alice = Data.struct({ name: "Alice", age: 30 })
 
const bob = Data.struct({ name: "Bob", age: 40 })
 
console.log(Equal.equals(alice, alice)) // Output: true
console.log(Equal.equals(alice, Data.struct({ name: "Alice", age: 30 }))) // Output: true
 
console.log(Equal.equals(alice, { name: "Alice", age: 30 })) // Output: false
console.log(Equal.equals(alice, bob)) // Output: false
ts
import { Equal, Data } from "effect"
 
const alice = Data.struct({ name: "Alice", age: 30 })
 
const bob = Data.struct({ name: "Bob", age: 40 })
 
console.log(Equal.equals(alice, alice)) // Output: true
console.log(Equal.equals(alice, Data.struct({ name: "Alice", age: 30 }))) // Output: true
 
console.log(Equal.equals(alice, { name: "Alice", age: 30 })) // Output: false
console.log(Equal.equals(alice, bob)) // Output: false

In this example, we use the Data.struct function to create structured data objects and check their equality using Equal.equals. The Data module simplifies the process by providing a default implementation for both Equal and Hash, allowing you to focus on comparing values without the need for explicit implementations.

The Data module isn't limited to just structs. It can handle various data types, including tuples, arrays, and records. If you're curious about how to leverage its full range of features, you can explore the Data module documentation.

Working with Collections

JavaScript's built-in Set and Map can be a bit tricky when it comes to checking equality:

ts
export const set = new Set()
 
set.add({ name: "Alice", age: 30 })
set.add({ name: "Alice", age: 30 })
 
console.log(set.size) // Output: 2
ts
export const set = new Set()
 
set.add({ name: "Alice", age: 30 })
set.add({ name: "Alice", age: 30 })
 
console.log(set.size) // Output: 2

Even though the two elements in the set have the same values, the set contains two elements. Why? JavaScript's Set checks for equality by reference, not by values.

To perform value-based equality checks, you'll need to use the Hash* collection types available in the effect package. These collection types, such as HashSet and HashMap, provide support for the Equal trait.

Let's take a closer look at how to use HashSet for value-based equality checks:

ts
import { HashSet, Data } from "effect"
 
const set = HashSet.empty().pipe(
HashSet.add(Data.struct({ name: "Alice", age: 30 })),
HashSet.add(Data.struct({ name: "Alice", age: 30 }))
)
 
console.log(HashSet.size(set)) // Output: 1
ts
import { HashSet, Data } from "effect"
 
const set = HashSet.empty().pipe(
HashSet.add(Data.struct({ name: "Alice", age: 30 })),
HashSet.add(Data.struct({ name: "Alice", age: 30 }))
)
 
console.log(HashSet.size(set)) // Output: 1

When you use the HashSet, it correctly handles value-based equality checks. In this example, even though you're adding two objects with the same values, the HashSet treats them as a single element.

Note: It's crucial to use elements that implement the Equal trait, either by implementing custom equality checks or by using the Data module. This ensures proper functionality when working with HashSet. Without this, you'll encounter the same behavior as the native Set data type:

ts
import { HashSet } from "effect"
 
const set = HashSet.empty().pipe(
HashSet.add({ name: "Alice", age: 30 }),
HashSet.add({ name: "Alice", age: 30 })
)
 
console.log(HashSet.size(set)) // Output: 2
ts
import { HashSet } from "effect"
 
const set = HashSet.empty().pipe(
HashSet.add({ name: "Alice", age: 30 }),
HashSet.add({ name: "Alice", age: 30 })
)
 
console.log(HashSet.size(set)) // Output: 2

In this case, without using the Data module alongside HashSet, you'll experience the same behavior as the native Set data type. The set contains two elements because it checks for equality by reference, not by values.

When working with the HashMap, you have the advantage of comparing keys by their values instead of their references. This is particularly helpful in scenarios where you want to associate values with keys based on their content.

Let's explore this concept with a practical example:

ts
import { HashMap, Data } from "effect"
 
const map = HashMap.empty().pipe(
HashMap.set(Data.struct({ name: "Alice", age: 30 }), 1),
HashMap.set(Data.struct({ name: "Alice", age: 30 }), 2)
)
 
console.log(HashMap.size(map)) // Output: 1
 
console.log(HashMap.get(map, Data.struct({ name: "Alice", age: 30 })))
/*
Output:
{
_id: "Option",
_tag: "Some",
value: 2
}
*/
ts
import { HashMap, Data } from "effect"
 
const map = HashMap.empty().pipe(
HashMap.set(Data.struct({ name: "Alice", age: 30 }), 1),
HashMap.set(Data.struct({ name: "Alice", age: 30 }), 2)
)
 
console.log(HashMap.size(map)) // Output: 1
 
console.log(HashMap.get(map, Data.struct({ name: "Alice", age: 30 })))
/*
Output:
{
_id: "Option",
_tag: "Some",
value: 2
}
*/

In this code snippet, we use the HashMap data structure to create a map where keys are objects created using Data.struct. These objects have the same values, which would typically result in multiple entries in a traditional JavaScript map.

However, with HashMap, the keys are compared by their values rather than their memory references. As a result, even though we add two objects with identical content as keys, the map correctly handles them as a single key-value pair.

To retrieve a value associated with a specific key, we can use HashMap.get. In this example, when we query the map with an object having the same values as the key, it returns the associated value, which is 2.