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:

LevelStringObject
SemanticsComparison by valueComparison by reference
ImplementationOptimization: pointers first, then contentPointers 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

TypeComparison ===Why?
PrimitivesBy valueThey are immutable, can be interned
ObjectsBy referenceThey are mutable, each definition = new allocation

That’s why {} !== {} - these are two different objects in memory, even if they look identical! :)