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 objectsconsole.log({ name: "Alice", roles: ["admin"] } === { name: "Alice", roles: ["admin"] })// false
// Structural equality compares the contents insteadconsole.log( Equal.equals( { name: "Alice", roles: ["admin"] }, { name: "Alice", roles: ["admin"] } ))// true
// It works recursively, on Maps/Sets (order-independent), and with NaNconsole.log(Equal.equals(new Map([["a", 1]]), new Map([["a", 1]]))) // trueconsole.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 collectionconst eq = Equal.asEquivalence<number>()console.log(Array.dedupeWith([1, 2, 2, 3, 1], eq)) // [1, 2, 3]The Hash companion
Section titled “The Hash companion”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)istrue, thenHash.hash(a)must equalHash.hash(b).
The Equal interface extends Hash, so any type with custom equality must
also provide a matching hash.
Custom equality on a class
Section titled “Custom equality on a class”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 referenceclass 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)) // 1Data classes get this for free
Section titled “Data classes get this for free”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)) // trueGotchas
Section titled “Gotchas”- Treat values as immutable after comparing them. Comparison and hash
results are cached per object. Mutating an object after its first
Equal.equalsorHash.hashcall 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 aHashMap. Always derive the hash from the fields you compare. - One-sided
Equalis never equal. If only one of two operands implementsEqual,Equal.equalsreturnsfalse. - Functions and
NaN. Functions without anEqualimplementation compare by reference;NaNis treated as equal toNaN(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)See also
Section titled “See also”- Equivalence — situational equality relations when one
canonical
Equalis not enough. - Data Types — value classes and tagged unions with built-in equality.
- State Management and Caching —
HashMapandHashSetrely on theEqual/Hashcontract.