Skip to content

Equivalence

An Equivalence<A> is a function (a: A, b: A) => boolean that decides when two values should be treated as the same — for some specific purpose. Unlike Equal, which is the single canonical structural equality used throughout Effect, you can define many equivalences for one type: compare strings case-insensitively, dates by their timestamp, or users by their id alone.

Every equivalence must be reflexive (eq(a, a) is true), symmetric, and transitive. Many Effect APIs accept an EquivalenceArray.dedupeWith, Array.containsWith, and others — so a custom equivalence is often all you need to make a built-in operation behave the way your domain expects.

import { Array, Equivalence } from "effect"
// Two strings are "the same" if they match ignoring case
const caseInsensitive = Equivalence.make<string>(
(a, b) => a.toLowerCase() === b.toLowerCase()
)
console.log(caseInsensitive("Hello", "HELLO")) // true
// Deduplicate using that notion of sameness
console.log(Array.dedupeWith(["Hello", "world", "HELLO", "World"], caseInsensitive))
// ["Hello", "world"]

Equivalence.make adds a fast reference-equality (===) check before calling your function, so identical references short-circuit to true.

Two combinators do most of the work. mapInput derives an equivalence for a larger type by projecting out the part you care about; combine ANDs two equivalences together so both must agree.

import { Equivalence } from "effect"
interface User {
readonly id: number
readonly name: string
readonly email: string
}
// Compare users by id only — name and email are ignored
const byId = Equivalence.mapInput(
Equivalence.strictEqual<number>(),
(user: User) => user.id
)
const a = { id: 1, name: "Alice", email: "alice@example.com" }
const b = { id: 1, name: "Alice Smith", email: "alice@work.com" }
const c = { id: 2, name: "Bob", email: "bob@example.com" }
console.log(byId(a, b)) // true (same id)
console.log(byId(a, c)) // false (different id)

combine chains two equivalences; both have to hold. The second is only checked when the first returns true (short-circuiting):

import { Equivalence } from "effect"
interface User {
readonly name: string
readonly age: number
}
const byName = Equivalence.mapInput(
Equivalence.strictEqual<string>(),
(u: User) => u.name
)
const byAge = Equivalence.mapInput(
Equivalence.strictEqual<number>(),
(u: User) => u.age
)
// Equivalent only when BOTH name and age match
const sameUser = Equivalence.combine(byName, byAge)
console.log(sameUser({ name: "Alice", age: 30 }, { name: "Alice", age: 30 })) // true
console.log(sameUser({ name: "Alice", age: 30 }, { name: "Alice", age: 31 })) // false

You rarely build struct equivalences field-by-field with combine — there are direct combinators. Equivalence.Struct takes a per-field equivalence and only compares the listed fields (extra fields are ignored). Tuple, Array, and Record cover the other shapes.

import { Equivalence } from "effect"
interface Person {
readonly name: string
readonly age: number
readonly email: string
}
// Names and emails compare case-insensitively; age must match exactly
const caseInsensitive = Equivalence.mapInput(
Equivalence.strictEqual<string>(),
(s: string) => s.toLowerCase()
)
const personEq = Equivalence.Struct({
name: caseInsensitive,
age: Equivalence.Number,
email: caseInsensitive
})
const p1 = { name: "Alice", age: 30, email: "alice@example.com" }
const p2 = { name: "ALICE", age: 30, email: "ALICE@EXAMPLE.COM" }
console.log(personEq(p1, p2)) // true (different casing, same person)

The other structured combinators follow the same pattern:

import { Equivalence } from "effect"
// Tuple: a different equivalence per position
const point = Equivalence.Tuple([Equivalence.Number, Equivalence.Number])
console.log(point([1, 2], [1, 2])) // true
// Array: one equivalence for every element; lengths must match
const tags = Equivalence.Array(Equivalence.String)
console.log(tags(["a", "b"], ["a", "b"])) // true
console.log(tags(["a"], ["a", "b"])) // false (different length)
// Record: compares all keys; both objects need the same key set
const scores = Equivalence.Record(Equivalence.Number)
console.log(scores({ alice: 1 }, { alice: 1 })) // true

If you already describe your data with Schema, you do not need to assemble an equivalence by hand — Schema.toEquivalence derives one that matches the schema’s structure.

import { Schema } from "effect"
const User = Schema.Struct({
id: Schema.Number,
name: Schema.String
})
const eq = Schema.toEquivalence(User)
console.log(eq({ id: 1, name: "Alice" }, { id: 1, name: "Alice" })) // true
console.log(eq({ id: 1, name: "Alice" }, { id: 2, name: "Alice" })) // false

Both answer “are these the same?”, but they fill different roles:

  • Equal is the one structural equality baked into Effect. Types implement it (or inherit it from Data), and hash-based collections rely on it. Use it when you want “same contents” without configuration.
  • Equivalence is a value you build and pass in to define situational sameness for a specific operation. Use it when “the same” depends on context — ignoring case, comparing by a key, applying a numeric tolerance.

You can bridge from one to the other when needed: Equal.asEquivalence() wraps the canonical structural equality as an Equivalence for APIs that expect one.

import { Array, Equal } from "effect"
const result = Array.dedupeWith([1, 2, 2, 3], Equal.asEquivalence<number>())
console.log(result) // [1, 2, 3]

Everything the Equivalence module exports. The combinators and structured helpers above are repeated here as compact entries so the whole module is scannable in one place.

The core type: a function (self: A, that: A) => boolean that must be reflexive, symmetric, and transitive. It is contravariant in A, so an Equivalence<unknown> can be used wherever an Equivalence<string> is expected.

import type { Equivalence } from "effect"
const pointEq: Equivalence.Equivalence<{ x: number; y: number }> = (a, b) =>
a.x === b.x && a.y === b.y
console.log(pointEq({ x: 1, y: 2 }, { x: 1, y: 2 })) // => true
console.log(pointEq({ x: 1, y: 2 }, { x: 9, y: 2 })) // => false

Wraps a predicate as an Equivalence, adding a === fast path: identical references return true without calling your function.

import { Equivalence } from "effect"
const tolerance = Equivalence.make<number>((a, b) => Math.abs(a - b) < 0.0001)
console.log(tolerance(1.0, 1.00001)) // => true
console.log(tolerance(1.0, 1.001)) // => false

Builds an equivalence backed by raw ===. Good for primitives and reference equality; note NaN is never equal to itself and objects compare by reference.

import { Equivalence } from "effect"
const eq = Equivalence.strictEqual<number>()
console.log(eq(1, 1)) // => true
console.log(eq(NaN, NaN)) // => false (NaN !== NaN)

Built-in instance for strings using ===.

import { Equivalence } from "effect"
console.log(Equivalence.String("hello", "hello")) // => true
console.log(Equivalence.String("hello", "world")) // => false

Built-in instance for numbers. Unlike ===, it treats NaN as equal to NaN.

import { Equivalence } from "effect"
console.log(Equivalence.Number(1, 1)) // => true
console.log(Equivalence.Number(NaN, NaN)) // => true

Built-in instance for booleans using ===.

import { Equivalence } from "effect"
console.log(Equivalence.Boolean(true, true)) // => true
console.log(Equivalence.Boolean(true, false)) // => false

Built-in instance for bigints using ===.

import { Equivalence } from "effect"
console.log(Equivalence.BigInt(1n, 1n)) // => true
console.log(Equivalence.BigInt(1n, 2n)) // => false

Built-in instance for Date values. Compares by getTime() via Equivalence.Number, so distinct instances with the same timestamp are equal, and two invalid dates (both NaN time) are equal too.

import { Equivalence } from "effect"
const a = new Date("2020-01-01T00:00:00.000Z")
const b = new Date("2020-01-01T00:00:00.000Z")
console.log(a === b) // => false (different references)
console.log(Equivalence.Date(a, b)) // => true (same time value)
console.log(Equivalence.Date(new Date("foo"), new Date("bar"))) // => true (both invalid)

ANDs two equivalences. The second is only evaluated when the first returns true (short-circuiting).

import { Equivalence } from "effect"
const byLength = Equivalence.mapInput(Equivalence.Number, (s: string) => s.length)
const sameFirst = Equivalence.mapInput(Equivalence.String, (s: string) => s[0])
const eq = Equivalence.combine(byLength, sameFirst)
console.log(eq("cat", "cup")) // => true (len 3, first "c")
console.log(eq("cat", "dog")) // => false (different first char)

ANDs an iterable of equivalences. An empty iterable yields an equivalence that always returns true.

import { Equivalence } from "effect"
const eq = Equivalence.combineAll([Equivalence.Number, Equivalence.Number])
console.log(eq(1, 1)) // => true
const always = Equivalence.combineAll<number>([])
console.log(always(1, 2)) // => true (empty collection)

Derives an equivalence for a new type by projecting each input before comparing.

import { Equivalence } from "effect"
const caseInsensitive = Equivalence.mapInput(
Equivalence.String,
(s: string) => s.toLowerCase()
)
console.log(caseInsensitive("Hello", "HELLO")) // => true
console.log(caseInsensitive("Hello", "World")) // => false

Builds an equivalence for fixed-length tuples, applying one equivalence per position. Tuples of different lengths are never equivalent.

import { Equivalence } from "effect"
const eq = Equivalence.Tuple([Equivalence.String, Equivalence.Number])
console.log(eq(["a", 1], ["a", 1])) // => true
console.log(eq(["a", 1], ["a", 2])) // => false

Builds an equivalence for objects from a per-field equivalence. Only the listed fields are compared; extra properties are ignored. Symbol keys are supported.

import { Equivalence } from "effect"
const eq = Equivalence.Struct({
name: Equivalence.String,
age: Equivalence.Number
})
console.log(eq({ name: "Alice", age: 30, extra: 1 }, { name: "Alice", age: 30, extra: 2 })) // => true
console.log(eq({ name: "Alice", age: 30 }, { name: "Alice", age: 31 })) // => false

Builds an equivalence for records/dictionaries, comparing every value with the same equivalence. Both objects must have the exact same set of keys.

import { Equivalence } from "effect"
const eq = Equivalence.Record(Equivalence.Number)
console.log(eq({ a: 1, b: 2 }, { a: 1, b: 2 })) // => true
console.log(eq({ a: 1, b: 2 }, { a: 1 })) // => false (different keys)

Builds an equivalence for arrays, comparing elements positionally with a single equivalence. Lengths must match; empty arrays are equivalent.

import { Equivalence } from "effect"
const eq = Equivalence.Array(Equivalence.Number)
console.log(eq([1, 2, 3], [1, 2, 3])) // => true
console.log(eq([1, 2], [1, 2, 3])) // => false (different length)
console.log(eq([], [])) // => true

Creates a Reducer of Equivalence<A> whose combine/combineAll AND equivalences together, with an always-true identity. Useful when folding a collection of equivalences.

import { Equivalence } from "effect"
const reducer = Equivalence.makeReducer<number>()
const combined = reducer.combineAll([
Equivalence.Number,
Equivalence.make<number>((a, b) => Math.abs(a - b) < 1)
])
console.log(combined(1, 1)) // => true
console.log(combined(1, 1.5)) // => false (strict number check fails)
console.log(reducer.initialValue(1, 2)) // => true (identity is always-true)

The higher-kinded type lambda for Equivalence, used by Effect’s HKT machinery. You will rarely reference it directly in application code.

  • Equal & Hash — the canonical structural equality and its hashing contract.
  • Order — when you need an ordering, not just equality.
  • Schema — derive equivalences (and more) from your data definitions.