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 caseconst caseInsensitive = Equivalence.make<string>( (a, b) => a.toLowerCase() === b.toLowerCase())
console.log(caseInsensitive("Hello", "HELLO")) // true
// Deduplicate using that notion of samenessconsole.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.
Building blocks: mapInput and combine
Section titled “Building blocks: mapInput and combine”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 ignoredconst 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 matchconst sameUser = Equivalence.combine(byName, byAge)
console.log(sameUser({ name: "Alice", age: 30 }, { name: "Alice", age: 30 })) // trueconsole.log(sameUser({ name: "Alice", age: 30 }, { name: "Alice", age: 31 })) // falseStructured equivalences
Section titled “Structured equivalences”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 exactlyconst 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 positionconst point = Equivalence.Tuple([Equivalence.Number, Equivalence.Number])console.log(point([1, 2], [1, 2])) // true
// Array: one equivalence for every element; lengths must matchconst tags = Equivalence.Array(Equivalence.String)console.log(tags(["a", "b"], ["a", "b"])) // trueconsole.log(tags(["a"], ["a", "b"])) // false (different length)
// Record: compares all keys; both objects need the same key setconst scores = Equivalence.Record(Equivalence.Number)console.log(scores({ alice: 1 }, { alice: 1 })) // trueDeriving an equivalence from a Schema
Section titled “Deriving an equivalence from a Schema”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" })) // trueconsole.log(eq({ id: 1, name: "Alice" }, { id: 2, name: "Alice" })) // falseEquivalence vs. Equal
Section titled “Equivalence vs. Equal”Both answer “are these the same?”, but they fill different roles:
Equalis the one structural equality baked into Effect. Types implement it (or inherit it fromData), and hash-based collections rely on it. Use it when you want “same contents” without configuration.Equivalenceis 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]See also
Section titled “See also”- 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.