Skip to content

Equal & Hash

In JavaScript, === compares objects by reference: two distinct objects are never equal, even if every field matches. That makes it awkward to ask the question you usually care about — do these two values represent the same thing?

The Equal module answers that question with structural equality: Equal.equals walks both values and compares their contents. It works out of the box for primitives, arrays, plain objects, Map, Set, Date, and RegExp, and any type can opt into custom equality by implementing the Equal interface.

import { Equal } from "effect"
// Reference equality says these are different objects
console.log({ name: "Alice", roles: ["admin"] } === { name: "Alice", roles: ["admin"] })
// false
// Structural equality compares the contents instead
console.log(
Equal.equals(
{ name: "Alice", roles: ["admin"] },
{ name: "Alice", roles: ["admin"] }
)
)
// true
// It works recursively, on Maps/Sets (order-independent), and with NaN
console.log(Equal.equals(new Map([["a", 1]]), new Map([["a", 1]]))) // true
console.log(Equal.equals(NaN, NaN)) // true (unlike ===)

Equal.equals never throws and always returns a boolean. Its curried form is handy for building predicates:

import { Array, Equal } from "effect"
const is42 = Equal.equals(42)
console.log(is42(42)) // true
// Use Equal semantics to deduplicate a collection
const eq = Equal.asEquivalence<number>()
console.log(Array.dedupeWith([1, 2, 2, 3, 1], eq)) // [1, 2, 3]

Structural comparison can be expensive. Before comparing fields, Equal checks a cheap numeric hash of each value: if the hashes differ, the values cannot be equal and the comparison stops early. Hash-based collections like HashMap and HashSet use the same hash to bucket values.

A hash is a fingerprint, not a proof of equality — collisions are possible — so hashing and equality always travel together. This is the Hash contract:

If Equal.equals(a, b) is true, then Hash.hash(a) must equal Hash.hash(b).

The Equal interface extends Hash, so any type with custom equality must also provide a matching hash.

To give your own class value semantics, implement both [Equal.symbol] (equality) and [Hash.symbol] (hashing). Build the hash from the same fields you compare, so the contract holds automatically.

import { Equal, Hash } from "effect"
// A domain identifier that should compare by value, not by reference
class UserId implements Equal.Equal {
constructor(readonly region: string, readonly id: string) {}
// Equal: two UserIds match when both fields match
[Equal.symbol](that: Equal.Equal): boolean {
return (
that instanceof UserId &&
this.region === that.region &&
this.id === that.id
)
}
// Hash: derive from the SAME fields so the contract holds.
// Hash.combine folds the field hashes into one number.
[Hash.symbol](): number {
return Hash.combine(Hash.string(this.region))(Hash.string(this.id))
}
}
const a = new UserId("eu", "user-1")
const b = new UserId("eu", "user-1")
const c = new UserId("us", "user-1")
console.log(Equal.equals(a, b)) // true (same contents)
console.log(Equal.equals(a, c)) // false (different region)
console.log(a === b) // false (still distinct references)

Because a and b are now equal and hash to the same value, they behave as a single key in hash-based collections:

import { HashSet } from "effect"
const ids = HashSet.make(
new UserId("eu", "user-1"),
new UserId("eu", "user-1") // duplicate by value
)
console.log(HashSet.size(ids)) // 1
import { Data, Equal } from "effect"
class Person extends Data.Class<{
readonly name: string
readonly age: number
}> {}
const mike1 = new Person({ name: "Mike", age: 30 })
const mike2 = new Person({ name: "Mike", age: 30 })
console.log(Equal.equals(mike1, mike2)) // true
  • Treat values as immutable after comparing them. Comparison and hash results are cached per object. Mutating an object after its first Equal.equals or Hash.hash call yields stale results.
  • Implementing only one of the pair is unsafe. If you provide [Equal.symbol] but a hash that ignores the same fields, equal values may hash differently and silently disappear from a HashMap. Always derive the hash from the fields you compare.
  • One-sided Equal is never equal. If only one of two operands implements Equal, Equal.equals returns false.
  • Functions and NaN. Functions without an Equal implementation compare by reference; NaN is treated as equal to NaN (unlike ===).

When you need reference identity for a mutable object — so that two snapshots with the same contents are still considered different — opt out explicitly:

import { Equal } from "effect"
const a = { x: 1 }
const b = { x: 1 }
console.log(Equal.equals(a, b)) // true (structural)
const aRef = Equal.byReference(a)
console.log(Equal.equals(aRef, b)) // false (compared by reference)
console.log(aRef.x) // 1 (the proxy reads through to the original)
  • Equivalence — situational equality relations when one canonical Equal is not enough.
  • Data Types — value classes and tagged unions with built-in equality.
  • State Management and CachingHashMap and HashSet rely on the Equal/Hash contract.