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 Equivalence — Array.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.