@verifyhash/deep-equal
v0.1.0
Published
Zero-dependency structural deep equality for Node.js: plain objects, arrays (sparse-hole aware), Date, RegExp, Map/Set with structural object keys, ArrayBuffer/TypedArray/DataView (byte-wise), boxed primitives, NaN-equals-NaN with +0/-0 distinction, and c
Maintainers
Readme
@verifyhash/deep-equal
Zero-dependency structural deep equality for Node.js. One function, one
boolean, and a precisely documented rule for every awkward case: NaN,
±0, sparse arrays, Map/Set with object keys, typed arrays, boxed
primitives, prototype mismatches — and cyclic structures, which terminate
instead of blowing the stack.
Who it's for: anyone writing tests, cache-invalidation checks, or
change-detection who is tired of JSON.stringify(a) === JSON.stringify(b)
(which breaks on key order, undefined, NaN, Map, Set, cycles, and
typed arrays — all of which this handles correctly).
Install
npm install @verifyhash/deep-equalZero runtime dependencies. CommonJS, works on any maintained Node.js.
TypeScript declarations ship in the package (index.d.ts).
Example
const { deepEqual } = require('@verifyhash/deep-equal');
deepEqual({ a: 1, b: [NaN] }, { b: [NaN], a: 1 }); // true (key order irrelevant, NaN === NaN)
deepEqual(new Map([[{ id: 1 }, 'x']]),
new Map([[{ id: 1 }, 'x']])); // true (object keys match structurally)
deepEqual(new Set([NaN, 1]), new Set([1, NaN])); // true
deepEqual(0, -0); // false (+0 and -0 are distinct)
deepEqual([, 1], [undefined, 1]); // false (a hole is not an explicit undefined)
deepEqual(new Float64Array(1), new Uint8Array(8)); // false (same bytes, different type)
// Cycles terminate and compare correctly:
const a = { name: 'node' }; a.self = a;
const b = { name: 'node' }; b.self = b;
deepEqual(a, b); // trueAPI
deepEqual(a, b) => boolean
Returns true iff a and b are structurally equal under these rules:
| Category | Rule |
|---|---|
| Primitives | Object.is semantics: NaN equals NaN (unlike ===), and +0 does NOT equal -0 (unlike ===). Chosen because a deep-equal that says NaN !== NaN is useless in tests, and collapsing ±0 silently hides real sign bugs (1/x flips infinity sign). |
| null / undefined | Distinct from each other and from everything else. { a: undefined } does NOT equal {}. |
| Plain objects | Own enumerable string keys only; key order irrelevant. Symbol keys and non-enumerable properties are ignored — two objects differing only in symbol-keyed props are equal. |
| Prototypes | Both sides must share the same prototype object (identity). A plain object never equals a class instance with the same fields; Object.create(null) objects only equal other null-proto objects. This is the strict rule Node's assert.deepStrictEqual family uses; it keeps [] vs {} and Map vs Set false for free. |
| Arrays | Length + elements. Sparse holes are preserved: [, 1] equals [, 1] but not [undefined, 1]. Extra own enumerable props on an array are compared too. |
| Date | By getTime(). Two Invalid Dates (both NaN) are equal. |
| RegExp | By source and flags. lastIndex is ignored. |
| Map | Equal size, and every [key, value] entry matches an unused entry of the other map — keys and values both compared structurally, so {id:1} keys match across maps. Insertion order irrelevant. |
| Set | Equal size, every member structurally matches an unused member. NaN in a Set works (Sets use SameValueZero internally). Insertion order irrelevant. |
| ArrayBuffer / TypedArrays / DataView | Byte-wise, and only against the same type: a Float64Array never equals a Uint8Array even over identical bytes. A subarray() view compares only its own window, not the whole backing buffer. |
| Boxed primitives | new Number(3) equals new Number(3) (unwrapped via valueOf, Object.is semantics) but never equals primitive 3 — object vs primitive is a type mismatch. |
| Functions | Reference equality only. Two distinct closures with identical source are not equal. |
| Cycles | Self-referential and mutually-referential structures terminate via an in-progress pair memo (WeakMap). Equality is coinductive: two cycles are equal when their infinite unrollings agree — so a 1-cycle a→a equals a 2-cycle b→b'→b when every payload matches. A cyclic structure never equals an acyclic one of different shape. |
Honest limits
- Symbol-keyed and non-enumerable properties are invisible to the comparison, by design. If those carry meaning for you, this is the wrong tool.
- Map/Set matching of object keys/members uses a greedy scan, not a full
bipartite matcher. With multiple structurally-equal-but-distinct candidate
keys mapping to different values, a pathological arrangement could greedily
mis-pair and report
falsewhere a smarter matcher finds a pairing. Sizes and normal data never hit this. - Object-keyed
Map/Setcomparison is O(n·m) in the worst case (every key of one map scanned against the other). Fine for the hundreds of entries typical in tests/config; do not diff two million-entry object-keyed maps with it. - Getters are invoked (values are read as a consumer would see them), and
cross-realm objects (e.g. from
node:vm) fail the prototype-identity rule. - Floats in typed arrays compare byte-wise:
Float64Array [0]vs[-0]differ (different bit patterns), and twoNaNs with different bit payloads would differ — plain-numberNaNs (outside typed arrays) always compare equal.
Running the tests
cd deep-equal
node test/index.test.js231 deterministic golden-vector checks (every table row is asserted in both argument orders), no timers, no network, exit code 0 on success.
License
MIT
