- β Handles circular references correctly
- β Supports all modern JS data types
- β Correctly handles special cases like NaN comparisons
- β Lightweight with zero dependencies
- β Fast, while handling all cases
When comparing objects in JavaScript, the built-in equality operators (== and ===) only check for reference
equality, not structural equality. This means that two objects with the same properties and values will be considered
different if they're not the same instance.
Many deep equality solutions exist, but they often have limitations:
- Some don't handle circular references
- Some have inconsistent behavior with special values like NaN
- Some don't support newer JavaScript features or types
This package aims to provide a comprehensive single solution that addresses all these concerns while maintaining excellent performance.
# Using npm
npm install @ver0/deep-equal
# Using yarn
yarn add @ver0/deep-equal
# Using pnpm
pnpm add @ver0/deep-equalThe API is extremely simple - just import the isEqual function and use it to compare any two values:
import {isEqual} from '@ver0/deep-equal';
// Comparing objects
isEqual({a: 1, b: 2}, {a: 1, b: 2}); // true
isEqual({a: 1, b: 2}, {a: 1, b: 3}); // false
// Handling circular references
const obj1 = {a: 1};
const obj2 = {a: 1};
obj1.self = obj1;
obj2.self = obj2;
isEqual(obj1, obj2); // true
// Works with various data types and containers
isEqual(new Date('2023-01-01'), new Date('2023-01-01')); // true
isEqual(new Set([1, 2]), new Set([1, 2])); // true
isEqual(new Map([['a', 1]]), new Map([['a', 1]])); // true
isEqual(/abc/g, /abc/g); // true
// Correctly handles special cases
isEqual(NaN, NaN); // truePrimitive values (numbers, strings, booleans, undefined, null) are compared using strict equality (===).
NaN is a special case β JavaScript's === operator considers NaN !== NaN, but isEqual treats two NaN values
as equal. This also applies to boxed NaN values: isEqual(Object(NaN), Object(NaN)) returns true.
Boxed primitives (new Number(), new String(), new Boolean()) are compared by their underlying value using
valueOf().
Two objects are first checked for matching prototypes β if their prototypes differ, they are not equal.
Then, both objects must have the same number of own enumerable string-keyed properties. Each property value is compared recursively.
Symbol-keyed properties are not compared. Symbols are designed to be non-enumerable identifiers β they serve as
hidden metadata rather than data-carrying properties. Well-known symbols like Symbol.iterator define behavior, and
framework-specific symbols like $$typeof are internal markers. Comparing them as data would contradict their intended
role in the language.
Arrays must have the same length. Each element is compared recursively by index.
A Set is an implementation of a mathematical set β an unordered collection of unique values. The fact that JavaScript
preserves insertion order during iteration is a convenience of the specification, not a defining characteristic. A Set
is not an array, and comparing it like one would be incorrect.
Sets must have the same size. Membership is checked using the Set's built-in has() method, which uses the
SameValueZero
algorithm. Order is irrelevant.
// Order does not matter
isEqual(new Set([1, 2, 3]), new Set([3, 1, 2])); // true
// Object references β compared by identity, not structure
isEqual(new Set([{a: 1}]), new Set([{a: 1}])); // falseObject elements are compared by reference, not by deep equality. This follows from how the Set itself defines
uniqueness. A Set uses SameValueZero to determine whether a value already exists β you can add 100 structurally
identical objects and the Set will hold all 100 as distinct elements. Deep-comparing elements would impose an identity
model that contradicts the container's own semantics. isEqual respects the data structure's definition of membership
rather than overriding it.
A Map is a key-value collection where keys are identified by SameValueZero β the same identity model as Sets. Just
like with Sets, this means object keys are distinct references, not interchangeable structures. Two structurally
identical objects used as Map keys are two different keys as far as the Map is concerned.
Maps must have the same size. Keys are compared by reference using the Map's built-in has() method, while
values are deep-compared recursively.
// Primitive keys β works as expected
isEqual(new Map([['a', {x: 1}]]), new Map([['a', {x: 1}]])); // true
// Object keys β compared by identity, not structure
const k1 = {id: 1};
const k2 = {id: 1};
isEqual(new Map([[k1, 'v']]), new Map([[k2, 'v']])); // falseThe reasoning is the same as for Sets β isEqual respects the Map's own definition of key identity rather than
overriding it.
Dates are compared by their timestamp value (getTime()). Regular expressions are compared by their source and
flags properties.
All binary data types β ArrayBuffer, SharedArrayBuffer, DataView, and all TypedArray variants β are compared at
the byte level using Uint8Array.
For TypedArrays and DataViews, only the viewed slice is compared. The byteOffset and byteLength of the view are
respected, so two views into the same underlying buffer that cover different regions are correctly identified as
different.
Byte-level comparison has a correctness advantage: it preserves NaN bit patterns. A Float64Array([NaN]) compared
element-by-element would fail because NaN !== NaN, but comparing the underlying bytes works correctly.
When two objects share the same prototype but don't match any of the built-in types above, isEqual checks whether
the class defines a custom valueOf() or toString() method.
If valueOf() is present, differs from Object.prototype.valueOf, and both instances share the same function
reference (which is naturally true for prototype methods), the comparison is performed by calling valueOf() on each
instance and comparing the results. The same logic applies as a fallback to toString().
This is a terminal comparison β if valueOf() or toString() is used, own properties are not checked afterward.
The rationale is that a class defining valueOf() is declaring "this is my primitive representation," and that
representation is the basis for equality.
Classes that don't define custom valueOf() or toString() fall through to property-by-property comparison, just like
plain objects.
Circular and cross-references between objects are handled correctly. Visited object pairs are tracked during recursion to detect cycles, so self-referential structures are compared without infinite loops.
Check out the benchmarks by running yarn benchmark in the project directory.
Unlike most deep equality libraries, this package supports circular reference detection. This requires tracking visited object pairs during recursion, which adds overhead on recursive structures (objects, arrays, maps).
To minimize this cost, the tracking mechanism is lazily allocated β comparisons of leaf types like dates, regular expressions, sets, and typed arrays have zero tracking overhead. The cost is only paid when recursion actually occurs.
Rather than splitting into separate functions (one with cycle detection, one without), this package provides a single function that handles all cases. Simplicity in both the API and the implementation.
- Primitive values (numbers, strings, booleans, undefined, null)
- NaN (correctly compared to be equal to itself)
- Boxed primitives (Number objects, String objects, Boolean objects)
- Plain objects
- Arrays
- Sets
- Maps
- Regular Expressions
- Date objects
- ArrayBuffers
- SharedArrayBuffers
- DataViews
- TypedArrays (Int8Array, Uint8Array, Float64Array, etc.)
- Objects with null prototypes
- Objects with custom
valueOf()/toString() - Any objects with circular references