Writing Your Own Comparison Function
=== only compares objects by reference identity, and JSON.stringify fails on key order, maps, sets and dates. A hand written deep equality function compares values recursively by structure instead of reference and covers exactly the data types the simple approaches regularly fail on.
Table of Contents
- 1. Why === and JSON.stringify Fail for Object Comparisons
- 2. Basic Skeleton of a Recursive deepEqual Function
- 3. Primitive Values, NaN and Object.is Semantics
- 4. Comparing Arrays Recursively
- 5. Objects: Comparing Key Sets and Values
- 6. Special Cases: Handling Map, Set and Date Correctly
- 7. Detecting Circular References Without an Infinite Loop
- 8. Performance: When Deep Equality Is Worth It
- 9. Your Own Implementation Compared to Alternatives
- 10. Summary
- 11. FAQ
1. Why === and JSON.stringify Fail for Object Comparisons
The === operator compares objects in JavaScript exclusively by reference identity. Two separately created objects with identical content are always unequal under ===, because they occupy two different locations in memory. For deep equality, meaning comparison by structural match instead of by reference, === is therefore fundamentally unsuited and was never meant to be otherwise.
The obvious workaround, comparing two objects via JSON.stringify(a) === JSON.stringify(b), seems charming at first but has several real weaknesses. The order of object keys affects the result, even though it should be irrelevant for deep equality. undefined values are silently dropped during serialization. Maps, sets, dates and functions are either serialized incorrectly or not at all. Anyone who wants to implement deep equality correctly cannot avoid writing a custom recursive comparison function.
2. Basic Skeleton of a Recursive deepEqual Function
The basic skeleton of a deep equality function always follows the same pattern: first, reference equality is checked as a fast shortcut for the most common case, namely that both values are the same object. Then the type of both values is determined, and depending on the type either a simple value comparison or a recursive structural comparison is carried out. This type distinction is the core of every correct deep equality implementation.
The order of checks matters: primitive values and null must be handled before the actual object recursion, because typeof null === "object" would otherwise cause an error further down the line. A clean deepEqual function therefore always starts with explicit guards for null, primitive types and the reference equality shortcut, before the actual recursive logic kicks in.
function deepEqual(a, b) {
// Fast path: same reference or identical primitive
if (Object.is(a, b)) return true;
// Null and non-object types cannot be recursed into
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return false;
}
// Different constructors mean different structural shape
if (a.constructor !== b.constructor) return false;
// Dispatch to type-specific comparison logic
if (Array.isArray(a)) return compareArrays(a, b);
if (a instanceof Map) return compareMaps(a, b);
if (a instanceof Set) return compareSets(a, b);
if (a instanceof Date) return a.getTime() === b.getTime();
return compareObjects(a, b);
}
3. Primitive Values, NaN and Object.is Semantics
A subtle but important point in deep equality is the handling of NaN. With the regular equality operator, NaN === NaN is always false, even though both values should intuitively count as equal in terms of deep equality when they occupy the same position in two compared objects. Object.is correctly solves this problem, because Object.is(NaN, NaN) returns true, while the regular comparison operator fails here.
A second difference concerns +0 and -0: +0 === -0 is true, while Object.is(+0, -0) returns false. For most deep equality use cases, the behavior of === for zero is actually more desirable, which is why some implementations deliberately mix both semantics: Object.is for NaN handling, but without distinguishing positive and negative zero. The choice depends on the concrete use case and should be made deliberately.
4. Comparing Arrays Recursively
When comparing two arrays, a correct deep equality implementation first checks the length. If the lengths differ, the arrays can never be equal, and the function can return false immediately without even looking at the elements. Only once the lengths match does the function iterate over the indices and call deepEqual recursively for each pair of elements.
It is important that, unlike object keys, the order of elements matters for arrays: [1, 2, 3] and [3, 2, 1] are structurally not equal, even though they contain the same values. This distinction between order sensitive arrays and order independent object keys is a common failure point in hand written deep equality functions and should be explicitly covered in tests.
function compareArrays(a, b) {
if (a.length !== b.length) return false;
// Order matters for arrays — index by index comparison
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
5. Objects: Comparing Key Sets and Values
For ordinary objects, a deep equality function has to check two things: first, whether both objects have the same set of own, enumerable keys, and second, whether the values for each key are recursively equal. The set of keys can be determined with Object.keys, where the number of keys is compared first to quickly rule out asymmetric cases such as an object with one extra key.
To check whether a key exists in the other object, Object.hasOwn should be used instead of the classic hasOwnProperty method, to avoid problems with null prototype objects or overridden methods. Only once the key count and key existence match does the function compare the values themselves recursively via deepEqual.
function compareObjects(a, b) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => {
// Object.hasOwn avoids pitfalls with null prototypes or overridden methods
return Object.hasOwn(b, key) && deepEqual(a[key], b[key]);
});
}
6. Special Cases: Handling Map, Set and Date Correctly
Map, Set and Date cannot be handled with the generic object comparison logic, because their relevant data does not live in enumerable properties but in internal slots. For Date, comparing the timestamps via getTime() is sufficient. For Map, the sizes must first be compared, then for every key in the first map it must be checked whether the second map has a value that is recursively equal.
With Set, the situation is more complex, because sets have no keys to look up directly. For simple, primitive values, set.has(value) works, but for objects as set elements a naive approach with has is not enough, because has itself again checks reference equality. For the fully correct case, every element in the first set would have to be tested against every remaining element in the second set with deepEqual, which makes the complexity quadratic but remains unproblematic for the small sets typically encountered in practice.
function compareMaps(a, b) {
if (a.size !== b.size) return false;
for (const [key, valueA] of a) {
if (!b.has(key) || !deepEqual(valueA, b.get(key))) return false;
}
return true;
}
function compareSets(a, b) {
if (a.size !== b.size) return false;
const remaining = [...b];
return [...a].every((itemA) => {
const index = remaining.findIndex((itemB) => deepEqual(itemA, itemB));
if (index === -1) return false;
remaining.splice(index, 1); // avoid matching the same element twice
return true;
});
}
7. Detecting Circular References Without an Infinite Loop
If an object contains a reference to itself, for example through obj.self = obj, a naive recursive deepEqual function would run into an infinite loop and crash with a stack overflow. The solution is an additional parameter, usually a WeakMap, that caches object pairs already visited during the comparison. If the function encounters a pair already marked as "currently being compared", it returns true instead of recursing again.
This technique is the same one structuredClone uses internally for circular structures, except that here a comparison result is stored instead of a copy. For most practical use cases, such as configuration objects or API responses, circular references rarely occur. For a robust, generally usable deep equality function in a shared library, this safeguard should nonetheless be built in, to avoid crashes on unexpected data structures.
8. Performance: When Deep Equality Is Worth It
Unlike the simple reference comparison with ===, deep equality has a runtime complexity that depends on the size and nesting depth of the compared structures. For large, deeply nested objects, a full structural comparison can take noticeably longer than a simple reference comparison. In render critical code, for instance React components that run a deep equality check on every render, this can lead to measurable performance problems.
A sensible optimization is to use deep equality only where it is genuinely needed, for example when comparing state objects before an expensive re-render, and to rely on shallow comparisons or immutable data structures with structural sharing for frequent, performance critical paths, where reference equality already guarantees content equality. The choice between both approaches is a deliberate architectural decision, not merely an implementation detail.
9. Your Own Implementation Compared to Alternatives
The following table compares the common approaches to object comparison in JavaScript and shows when a custom deepEqual function is actually the right choice.
| Approach | Maps/Sets/Dates | Key Order Irrelevant | Circular References |
|---|---|---|---|
=== |
Reference only | Not applicable | No problem |
JSON.stringify comparison |
Wrong/missing | Relevant, should not be | Throws an exception |
Custom deepEqual function |
Handled correctly | Correctly ignored | Guardable with WeakMap |
| External library (lodash) | Handled correctly | Correctly ignored | Handled correctly |
An external library often remains the more pragmatic choice in large projects with many edge cases, but for small projects, bundle size sensitive applications, or for learning purposes, a custom deepEqual implementation shows precisely which pitfalls a correct object comparison really has.
Mironsoft
JavaScript architecture, data structures and performance
State comparisons that stay correct and performant?
We analyze existing comparison logic in your code, replace error prone JSON.stringify comparisons with robust deep equality patterns, and optimize performance critical comparison paths in state management and rendering.
Code Review
Identifying error prone object comparisons in existing code
Performance Tuning
Optimizing deep equality calls in render critical paths
Architecture Consulting
Introducing immutable data structures and structural sharing
10. Summary
A custom deep equality function replaces both the insufficient reference comparison with === and the error prone JSON.stringify trick with a recursive structure that handles primitive values with Object.is semantics, compares arrays order sensitively, checks objects by key set and values, and correctly handles map, set and date through their respective internal structure.
For robust, reusable implementations, two additional aspects are decisive: protection against circular references via WeakMap based visit tracking, and a deliberate understanding of the performance cost that deep equality incurs on large, deeply nested structures. Anyone who keeps these points in mind ends up with a few dozen lines of code providing a deep equality solution that suffices for most projects without pulling in an external library.
Implementing Deep Equality Yourself — Key Points at a Glance
Basic Structure
Fast path via Object.is, type guards for null/primitives, then type specific recursive comparison logic.
Special Cases
Map, Set and Date need their own comparison logic, generic object iteration is not enough here.
Circular References
WeakMap based visit tracking prevents infinite loops with self referencing objects.
Performance
Use deep equality selectively, not universally in every render cycle, to keep runtime cost bounded.