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]
  • 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.