An object is not equal to another object
In this post, we’ll explore something commonly encountered in programming languages - comparison operators.
In JavaScript, like in other programming languages, we have different data types that we can compare:
"test" === "test" // true
true === true // true
1 === 1 // true
But not everything is as obvious as logic would suggest:
{} === {} // false π€
[] === [] // false π€
Why does this happen?
Types in JavaScript
JavaScript is a dynamically typed language, so types are “tagged” in memory - after all, there needs to be some way to know what is what. This somewhat relieves the JavaScript programmer from thinking about what type is currently allocated.
There are two main types:
- Primitive - immutable, compared by value
- Reference - mutable, compared by reference
| Type | Immutable? | Storage | Comparison | Example |
|--------------------|------------|-------------------|-------------------|-----------------------------|
| Number (Int32) | β
YES | Stack (inline) | By value | 42 === 42 β true |
| Number (Float) | β
YES | Heap (HeapNumber) | By value | 3.14 β mutation impossible |
| String | β
YES | Heap (JSString) | By value* | "hello" === "hello" β true |
| Boolean | β
YES | Stack (inline) | By value | true === true β true |
| undefined | β
YES | Stack (inline) | By value | undefined === undefined |
| null | β
YES | Stack (inline) | By value | null === null β true |
| Symbol | β
YES | Heap (Symbol) | By reference | Symbol() !== Symbol() |
| BigInt | β
YES | Heap | By value* | 10n === 10n β true |
|--------------------|------------|-------------------|-------------------|-----------------------------|
| Object | β NO | Heap (JSObject) | By reference | {} !== {} |
| Array | β NO | Heap (JSObject) | By reference | [] !== [] |
| Function | β NO | Heap (JSFunction) | By reference | (() => {}) !== (() => {}) |
| Date | β NO | Heap (JSObject) | By reference | new Date() !== new Date() |
| RegExp | β NO | Heap (JSObject) | By reference | /a/ !== /a/ |
| Map/Set | β NO | Heap (JSObject) | By reference | new Map() !== new Map() |
Conclusion: Primitive types are types that cannot be changed (immutable).
But wait… what about toUpperCase()?
An inquisitive person might ask - if primitive types are immutable, then how does this relate to:
"test".toUpperCase() // "TEST"
We can easily investigate this using the %DebugPrint macro available in Node.js with the --allow-natives-syntax flag:
const str = "test";
%DebugPrint(str);
// DebugPrint: 0x36c70031c8c5: [String] in ReadOnlySpace: #test
// type: INTERNALIZED_ONE_BYTE_STRING_TYPE
%DebugPrint(str.toUpperCase());
// DebugPrint: 0x36c70084d251: [String]: "TEST"
// type: SEQ_ONE_BYTE_STRING_TYPE
Notice the difference in addresses:
"test"β0x36c70031c8c5"TEST"β0x36c70084d251
Conclusion: The value was copied to a new location in memory - the immutability principle was preserved!
Semantics vs Implementation
Since string "test" === "test" returns true, how does it work under the hood?
Let’s look at the string comparison implementation in the SpiderMonkey engine (Firefox):
bool js::EqualStrings(JSContext* cx, JSString* str1, JSString* str2,
bool* result) {
if (str1 == str2) {
*result = true;
return true;
}
// ... the rest of the function compares content when pointers differ
return EqualStringsPure(str1, str2, result);
}
The code above shows an optimization (fast path):
// Case 1: Pointer comparison - O(1)
if (str1 == str2) { // single CPU instruction!
return true;
}
// Case 2: Content comparison - O(n)
for (size_t i = 0; i < length; i++) {
if (str1->chars[i] != str2->chars[i]) {
return false;
}
}
This shows the difference between language semantics and implementation:
| Level | String | Object |
|---|---|---|
| Semantics | Comparison by value | Comparison by reference |
| Implementation | Optimization: pointers first, then content | Pointers only |
String Interning (Atom Table)
Why does "test" === "test" work fast?
JS engines use interning - string literals are stored in a special table (Atom Table in SpiderMonkey). This way, identical literals point to the same object in memory:
Stack: Heap (Atom Table):
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β s1 β 0x26a432b2c140 β ββββββββ 0x26a432b2c140: β
β s2 β 0x26a432b2c140 β ββββββββ JSString "test" β
ββββββββββββββββββββββββ β length: 4 β
β chars: "test" β
ββββββββββββββββββββββββ
Note: Not all strings are interned! Dynamically created strings (e.g.,
"te" + "st") may have different addresses - then the engine must compare content O(n).
Back to objects…
Objects and arrays are complex structures that are mutable - they can be modified at any time.
With each definition of a (seemingly empty) object:
{} === {} // false - ALWAYS!
the engine always allocates a new place in memory for it:
{} // allocated at address 0x7ffff66f7090
{} // allocated at address 0x7ffff66f7098 (different address!)
Key takeaway: When comparing objects/arrays, we don’t compare their values - only the address in memory, which will be different even for identical-looking objects!
What does the ECMA-262 standard say?
The ECMA-262 standard defines this behavior in the IsStrictlyEqual operation (used by the === operator):
7.2.16 IsStrictlyEqual ( x, y )
1. If Type(x) is not Type(y), return false.
2. If x is a Number, then
a. Return Number::equal(x, y).
3. Return SameValueNonNumber(x, y).
Where SameValueNonNumber for objects boils down to:
7.2.11 SameValueNonNumber ( x, y )
...
6. NOTE: All other ECMAScript language values are compared by identity.
7. If x is y, return true; otherwise return false.
So for objects, it checks whether x and y are the same object (the same reference in memory), not whether they have the same content.
Summary
| Type | Comparison === | Why? |
|---|---|---|
| Primitives | By value | They are immutable, can be interned |
| Objects | By reference | They are mutable, each definition = new allocation |
That’s why {} !== {} - these are two different objects in memory, even if they look identical! :)
