JAVASCRIPT / OBJECTS AND PROPERTY MECHANICS
Comparing objects by reference and by shape
Tell whether two objects are the same object or merely look alike, and write your own shallow shape comparison instead of trusting === or JSON.stringify.
What you will learn
- Predict === and Object.is results for object literals, aliases, and spread copies
- Write a shallow shape check with Object.keys, Object.hasOwn, and Object.is
- Name the cases where a JSON.stringify comparison silently gives the wrong answer
- Find objects in arrays by contents with find and findIndex instead of includes
Understanding Comparing objects by reference and by shape
An object value in JavaScript is a reference: the variable holds a handle to a slot in memory, not the properties themselves. Applying === to two references asks one question only, do these handles point at the same slot, and it never inspects the properties. That is why every object literal you evaluate is unequal to every other object, including one written with identical keys on the line above, and why {} === {} is false. Object.is behaves identically for objects; it diverges from === only for NaN and for -0 versus 0, which are primitive cases.
JavaScript has no built-in structural comparison, so "same shape" is whatever you define it to be, and you must pick a meaning: identical own key names, identical keys with equal values, matching types per key, or full deep equality. A shallow version compares Object.keys(a).length with Object.keys(b).length first as a cheap reject, then for each key checks that the other object actually owns that key and that the two values pass Object.is. The ownership check matters because b[k] returns undefined both for a missing key and for a key explicitly set to undefined, and using Object.is instead of === for the values makes two NaN fields compare equal.
The tempting shortcut, JSON.stringify(a) === JSON.stringify(b), compares serialized text and therefore inherits every quirk of serialization: key order follows insertion order and changes the string, undefined and function values vanish, NaN and Infinity become null, Dates become strings, Maps and Sets become {}, and a cyclic object throws. Identity comparison is not a defect to route around, though; it is what makes objects usable as Map keys and Set members, and what lets a framework decide in one pointer comparison that nothing changed. The flip side is that mutating an object through one reference is invisible to ===, because the identity is unchanged while the contents are not.
const a = { x: 1, y: 2 };
const b = { x: 1, y: 2 };
const alias = a;
console.log(a === b);
console.log(a === alias);
console.log(Object.is(a, b));
function shallowEqual(p, q) {
const keys = Object.keys(p);
if (keys.length !== Object.keys(q).length) return false;
return keys.every((k) => Object.hasOwn(q, k) && Object.is(p[k], q[k]));
}
console.log(shallowEqual(a, b));
console.log(shallowEqual(a, { y: 2, x: 1 }));
console.log(shallowEqual(a, { x: 1 }));
alias.x = 99;
console.log(b.x, a.x);=== on objects compares identity, which object it is, so any content-based comparison is a function you write yourself.
Worked examples
Why the JSON.stringify trick lies
Shows three ways serialized text stops matching the objects it came from.
const p = { id: 1, name: 'kite' };
const q = { name: 'kite', id: 1 };
console.log(JSON.stringify(p) === JSON.stringify(q));
const r = { tag: undefined, when: new Date(0) };
console.log(JSON.stringify(r));
console.log(JSON.stringify({ score: NaN }));Example explained
Line 1p and q hold the same pairs, but stringify walks keys in insertion order, so the two strings differ character by character and the comparison reports false.
Line 2tag: undefined is dropped entirely, so r serializes to the same text as an object that has no tag key at all.
Line 3NaN has no JSON representation and is emitted as null, making { score: NaN } and { score: null } indistinguishable by this test.
Arrays and Sets match by identity
Demonstrates that built-in membership checks use identity, so a look-alike literal is never found.
const point = { x: 0 };
const points = [point, { x: 1 }];
console.log(points.includes(point));
console.log(points.includes({ x: 1 }));
console.log(points.findIndex((p) => p.x === 1));
const seen = new Set([{ x: 0 }, { x: 0 }]);
console.log(seen.size);Example explained
Line 1includes compares with SameValueZero, which for objects is identity, so passing the reference you already hold succeeds.
Line 2The fresh { x: 1 } literal is a different object despite equal contents, so includes returns false.
Line 3findIndex takes a predicate, which is where you supply your own contents test instead of relying on identity.
Line 4A Set also stores members by identity, so two separately created { x: 0 } objects both fit and size is 2.
Comparing shape without comparing values
Builds a key-and-type signature so records with the same structure but different data match.
function signature(obj) {
return Object.keys(obj)
.sort()
.map((k) => k + ':' + typeof obj[k])
.join(',');
}
const draft = { title: 'Notes', tags: ['a'], done: false };
const other = { done: true, title: 'Log', tags: [] };
const partial = { title: 'Log' };
console.log(signature(draft));
console.log(signature(draft) === signature(other));
console.log(signature(draft) === signature(partial));Example explained
Line 1sort() normalizes the key list, so insertion order cannot change the signature the way it changes a JSON string.
Line 2Recording typeof per key deliberately ignores the data, so draft and other match even though every value differs.
Line 3partial is missing two keys, producing a shorter signature and a false result.
Line 4typeof reports "object" for arrays and for null, so this signature cannot distinguish tags: [] from tags: {}.
Important notes
A shallow check still compares nested objects by identity, so { p: { x: 1 } } and { p: { x: 1 } } come out unequal; deep comparison needs recursion plus protection against cycles.
Object.keys sees only own enumerable string keys, so inherited properties, symbol keys, and non-enumerable properties are invisible to any comparison built on it.
Common mistakes
Writing obj === {} to test for an empty object: the right side is a brand-new object, so the result is always false and the branch never runs. Use Object.keys(obj).length === 0.
Assuming a copy equals its source: { ...state } === state is false, so an identity-based cache or memo treats the copy as new data and redoes the work it was meant to skip.
Calling list.indexOf({ id: 3 }) to locate an entry: it returns -1 because the literal is a different object, and list.splice(-1, 1) then removes the last element instead of the intended one.
Try it yourself
Change, predict, then run
In a browser console, write sameKeys(a, b) that returns true when two objects have the same own key names regardless of order or values. Confirm it returns true for { a: 1, b: 2 } and { b: 9, a: 0 } but false for { a: 1 } and { a: 1, b: 2 }.
Open the JavaScript workspaceCheck your understanding
Given const a = { list: [1, 2] }; const b = { list: a.list }; what does a shallow equality check that compares own keys with Object.is return for a and b?
- true, because both list properties hold the same array reference
- false, because a and b are two different objects
- false, because Object.is compares arrays element by element and stops at the first index
- true, because Object.is treats arrays with equal elements as equal
Show answer
The check compares property values, and a.list and b.list are the very same array object, so Object.is succeeds and the key counts match. Option 1 confuses the identity of the outer objects, which the check deliberately ignores, with the value comparison it actually performs; option 3 is wrong because Object.is never looks inside an array, and had b.list been a fresh [1, 2] the result would have been false.