@mainframework/is-deep-equal
v1.1.0
Published
Deep equality comparison utility with support for objects, arrays, maps, sets, and circular refs
Maintainers
Readme
@mainframework/is-deep-equal
A robust, recursive deep equality checker that handles complex structures including Map, Set, Date, RegExp, Error, ArrayBuffer, DataView, typed arrays, nested objects, circular references, and more — without mutating the input.
Built in TypeScript. Outputs a minified ESM module for JavaScript and TypeScript projects.
Table of Contents
Installation
Using Yarn:
yarn add @mainframework/is-deep-equalUsing npm:
npm install @mainframework/is-deep-equalFeatures
- Deep comparison of primitives, arrays, objects,
Map,Set,Date,RegExp,Error, typed arrays,ArrayBuffer, andDataView - Handles circular references without infinite loops
- Order-independent comparison for
MapandSet - Compares enumerable symbol-keyed object properties
- Compares
NaNas equal to itself - Optional normalization of boxed primitives (
String,Number,Boolean) - Configurable sparse array handling
- Configurable prototype mismatch tolerance
- TypeScript support with
.d.tsdeclarations - ESM output for modern bundlers
API
isEqual(a: unknown, b: unknown, opts?: EqualityOptions): boolean| Parameter | Description |
|-----------|-------------|
| a | First value to compare |
| b | Second value to compare |
| opts | Optional comparison settings (see Options Reference) |
Returns true if a and b are deeply equal, false otherwise.
interface EqualityOptions {
strictSparseArrays?: boolean; // default: false — treat missing slots as distinct from undefined
normalizeBoxedPrimitives?: boolean; // default: false — unwrap boxed primitives before comparison
allowPrototypeMismatch?: boolean; // default: false — ignore prototype chain differences
}Usage
import { isEqual } from '@mainframework/is-deep-equal';Exported types are available from the same entry point:
import type { EqualityOptions } from '@mainframework/is-deep-equal';Supported Types
Primitives
Primitive values are compared with strict equality. NaN compares as equal to itself. Symbols are compared by identity, so Symbol.for (global registry) symbols with the same key are equal, while locally created symbols are not.
isEqual(1, 1) // true
isEqual('hello', 'hello') // true
isEqual(true, true) // true
isEqual(null, null) // true
isEqual(undefined, undefined) // true
isEqual(BigInt(42), BigInt(42)) // true
isEqual(Symbol.for('x'), Symbol.for('x')) // true
isEqual(1, 2) // false
isEqual(null, undefined) // false
isEqual(Symbol('x'), Symbol('x')) // false — different symbol instances
// NaN
isEqual(NaN, NaN) // true
isEqual(NaN, 0) // falseObjects
Objects are compared by their enumerable own properties (string-keyed and symbol-keyed). Key insertion order does not matter. By default, objects with different prototypes are not equal.
isEqual({ a: 1, b: 2 }, { b: 2, a: 1 }) // true — order independent
isEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }) // true — deeply nested
isEqual({ a: 1 }, { a: 2 }) // false — different value
isEqual({ a: 1 }, { b: 1 }) // false — different keySymbol-keyed properties — enumerable symbol properties are included in the comparison:
const sym = Symbol('key');
isEqual({ [sym]: 42 }, { [sym]: 42 }) // true
isEqual({ [sym]: 1 }, { [sym]: 2 }) // false
// Non-enumerable symbol keys are ignored
const hidden = Symbol('hidden');
const a = {};
Object.defineProperty(a, hidden, { value: 1, enumerable: false });
isEqual(a, {}) // trueArrays
Arrays are compared element-by-element in order. Nested arrays and mixed-type elements are fully traversed.
isEqual([1, 2, 3], [1, 2, 3]) // true
isEqual([[1], [2]], [[1], [2]]) // true
isEqual([1, 2], [2, 1]) // false — order matters
isEqual([1, 2], [1, 2, 3]) // false — different lengthSparse arrays — by default a missing slot and undefined are treated as equal. Enable strictSparseArrays to distinguish them:
isEqual([1, , 3], [1, undefined, 3]) // true (default)
isEqual([1, , 3], [1, undefined, 3], { strictSparseArrays: true }) // false
isEqual([1, , 3], [1, , 3], { strictSparseArrays: true }) // trueDate
Dates are compared by their UTC time value.
isEqual(new Date('2024-01-01'), new Date('2024-01-01')) // true
isEqual(new Date('2024-01-01'), new Date('2025-01-01')) // falseRegExp
Regular expressions are compared by source and flags.
isEqual(/abc/gi, /abc/gi) // true
isEqual(/abc/, /def/) // false — different source
isEqual(/abc/i, /abc/g) // false — different flagsError
Errors are compared by name, message, cause (deep), and any enumerable own properties added to the instance.
isEqual(new Error('oops'), new Error('oops')) // true
// Different error type
isEqual(new TypeError('oops'), new RangeError('oops')) // false
// Errors with nested cause
const cause = { code: 42 };
isEqual(
new Error('fail', { cause }),
new Error('fail', { cause: { code: 42 } })
) // true
// Errors with custom own properties
const e1 = Object.assign(new Error('oops'), { code: 404 });
const e2 = Object.assign(new Error('oops'), { code: 404 });
const e3 = Object.assign(new Error('oops'), { code: 500 });
isEqual(e1, e2) // true
isEqual(e1, e3) // falseMap
Maps are compared by their entries. Insertion order is ignored, and object keys are compared deeply.
// Insertion order does not matter
isEqual(
new Map([['a', 1], ['b', 2]]),
new Map([['b', 2], ['a', 1]])
) // true
// Different values
isEqual(new Map([['a', 1]]), new Map([['a', 2]])) // false
// Object keys compared deeply
isEqual(
new Map([[{ x: 1 }, { y: 2 }]]),
new Map([[{ x: 1 }, { y: 2 }]])
) // true
// Nested maps
const map1 = new Map([['users', new Map([['alice', { age: 30 }]])]]);
const map2 = new Map([['users', new Map([['alice', { age: 30 }]])]]);
isEqual(map1, map2) // trueSet
Sets are compared without regard to insertion order. Each element is matched by deep equality.
isEqual(new Set([1, 2, 3]), new Set([3, 1, 2])) // true — order independent
// Nested sets with object elements
const a = new Set([new Set([{ id: 1 }, { id: 2 }])]);
const b = new Set([new Set([{ id: 1 }, { id: 2 }])]);
isEqual(a, b) // true
isEqual(new Set([1, 2]), new Set([2, 3])) // falseComplex nested example — Map containing a Set with a nested Set and Map of objects:
const set1 = new Set([
new Set([{ foo: 'bar' }, { baz: 42 }]),
new Map([['one', { a: 1 }], ['two', { b: 2 }]]),
]);
const set2 = new Set([
new Set([{ foo: 'bar' }, { baz: 42 }]),
new Map([['one', { a: 1 }], ['two', { b: 2 }]]),
]);
isEqual(new Map([['data', set1]]), new Map([['data', set2]])) // trueTyped Arrays
Typed arrays of the same type are compared byte-by-byte. Arrays of different types are not equal even if the underlying bytes match.
isEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3])) // true
isEqual(new Int16Array([1, 2]), new Int16Array([1, 2])) // true
isEqual(new Uint8Array([1, 2]), new Int8Array([1, 2])) // false — different types
isEqual(new Uint8Array([1, 2]), new Uint8Array([2, 1])) // false — different valuesArrayBuffer
ArrayBuffers are compared byte-by-byte.
const a = new Uint8Array([1, 2, 3, 4]).buffer;
const b = new Uint8Array([1, 2, 3, 4]).buffer;
isEqual(a, b) // true
const c = new Uint8Array([1, 2, 3, 5]).buffer;
isEqual(a, c) // falseDataView
DataViews are compared by the bytes they describe, respecting byteOffset and byteLength.
const bufA = new Uint8Array([0, 10, 20, 30, 40]).buffer;
const bufB = new Uint8Array([0, 10, 20, 30, 40]).buffer;
// Views over the same slice
const a = new DataView(bufA, 1, 4);
const b = new DataView(bufB, 1, 4);
isEqual(a, b) // true
const bufC = new Uint8Array([0, 10, 20, 30, 99]).buffer;
const c = new DataView(bufC, 1, 4);
isEqual(a, c) // falseCircular References
Circular references are detected and handled without throwing. Objects that form the same circular structure are considered equal.
const a: any = { foo: {} };
a.foo.bar = a;
const b: any = { foo: {} };
b.foo.bar = b;
isEqual(a, b) // true
// Mismatched structure
const c: any = { foo: {} };
c.foo.bar = {};
isEqual(a, c) // falseKnown limitation: Structurally non-isomorphic cycles (for example, a two-node mutual reference vs. a self-loop) may incorrectly compare as equal. See Known Limitations.
Boxed Primitives
By default, new String('a') and 'a' are not equal — they have different types. Enable normalizeBoxedPrimitives to unwrap them before comparison.
// Default — no normalization
isEqual(new String('a'), 'a') // false
isEqual(new Number(1), 1) // false
isEqual(new Boolean(true), true) // false
// With normalization
isEqual(new String('a'), 'a', { normalizeBoxedPrimitives: true }) // true
isEqual(new Number(1), 1, { normalizeBoxedPrimitives: true }) // true
isEqual(new Boolean(true), true, { normalizeBoxedPrimitives: true }) // true
// Two boxed-primitive objects with extra own properties
const s1 = Object.assign(new String('a'), { x: 1 });
const s2 = Object.assign(new String('a'), { x: 1 });
isEqual(s1, s2) // true
const s3 = Object.assign(new String('a'), { x: 2 });
isEqual(s1, s3) // falsePrototype Mismatch
By default, objects with different prototypes are not equal, even if they have the same shape. Set allowPrototypeMismatch: true to ignore prototype differences.
class A { x = 1; }
class B { x = 1; }
isEqual(new A(), new B()) // false — different prototypes
isEqual(new A(), new B(), { allowPrototypeMismatch: true }) // true
// Object with no prototype vs plain object
const noProto = Object.create(null);
noProto.x = 1;
isEqual(noProto, { x: 1 }) // false
isEqual(noProto, { x: 1 }, { allowPrototypeMismatch: true }) // trueFunctions
Functions are compared by reference only. Two functions with identical source code but different identities are not equal.
const fn = () => {};
isEqual(fn, fn) // true — same reference
const fn2 = () => {};
isEqual(fn, fn2) // false — different referencesWeakMap, WeakSet, Promise
WeakMap, WeakSet, and Promise cannot be iterated or inspected for equality, so any comparison involving them always returns false.
isEqual(new WeakMap(), new WeakMap()) // false
isEqual(new WeakSet(), new WeakSet()) // false
isEqual(Promise.resolve(1), Promise.resolve(1)) // falseOptions Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| strictSparseArrays | boolean | false | When true, a missing array slot and an explicit undefined at the same index are treated as different |
| normalizeBoxedPrimitives | boolean | false | When true, boxed primitives (new String, new Number, new Boolean) are unwrapped to their primitive value before comparison |
| allowPrototypeMismatch | boolean | false | When true, objects with different prototype chains are considered equal if their enumerable own properties match |
Known Limitations
Circular reference non-isomorphism: When a cycle is detected, the comparison optimistically returns true to break recursion. As a result, two structurally different cycles — for example, a self-loop (a.self = a) versus a two-node mutual reference (a.next = b; b.next = a) — may be reported as equal when they are not.
WeakMap / WeakSet / Promise: These types are always false because their contents cannot be enumerated.
Tests
This library uses Vitest for testing.
yarn test
# or
npm run testTest coverage includes:
- Primitives and edge cases (
NaN,BigInt,Symbol) - Objects (key order, symbol-keyed properties, prototype mismatch)
- Arrays (nested, sparse)
Date,RegExp,Error(with cause and custom properties)MapandSet(insertion-order independence, object keys/values)- Typed arrays,
ArrayBuffer,DataView - Circular references
- Boxed primitives (with and without normalization)
- Functions,
WeakMap,WeakSet,Promise
Refer to the test/ folder for the full test suite.
License
MIT © is-deep-equal
