docs/concepts/primitives-objects.mdx
Have you ever wondered why changing one variable unexpectedly changes another? Why does this happen?
const original = { name: "Alice" };
const copy = original;
copy.name = "Bob";
console.log(original.name); // "Bob" — Wait, what?!
The answer lies in how JavaScript values behave — not where they're stored. Primitives are immutable and behave independently, while objects are mutable and can be shared between variables.
<Warning> **Myth vs Reality:** You may have heard that "primitives are stored on the stack" and "objects are stored on the heap," or that "primitives are passed by value" while "objects are passed by reference." These are simplifications that are technically incorrect. In this guide, we'll learn how JavaScript actually works. </Warning> <Info> **What you'll learn in this guide:** - The real difference between primitives and objects (it's about mutability, not storage) - Why JavaScript uses "call by sharing" — not "pass by value" or "pass by reference" - Why mutation works through function parameters but reassignment doesn't - Why `{} === {}` returns `false` (object identity) - How to properly clone objects (shallow vs deep copy) - Common bugs caused by shared references - **Bonus:** How V8 actually stores values in memory (the technical truth) </Info> <Warning> **Prerequisite:** This guide assumes you understand [Primitive Types](/concepts/primitive-types). If you're not familiar with the 7 primitive types in JavaScript, read that guide first! </Warning>Before we dive in, let's clear up some widespread misconceptions that even experienced developers get wrong.
<Info> **Myth vs Reality**| Common Myth | The Reality |
|---|---|
| "Value types" vs "reference types" | ECMAScript only defines primitives and objects |
| "Primitives are stored on the stack" | Implementation-specific — not in the spec |
| "Objects are stored on the heap" | Implementation-specific — not in the spec |
| "Primitives are passed by value" | JavaScript uses call by sharing for ALL values |
| "Objects are passed by reference" | Objects are passed by sharing (you can't reassign the original) |
The ECMAScript specification (the official JavaScript standard) defines exactly two categories of values. According to the 2023 State of JS survey, confusion around value vs reference behavior remains one of the most common pain points for developers learning JavaScript:
| ECMAScript Term | What It Includes |
|---|---|
| Primitive values | string, number, bigint, boolean, undefined, null, symbol |
| Objects | Everything else (plain objects, arrays, functions, dates, maps, sets, etc.) |
That's it. The spec never mentions "value types," "reference types," "stack," or "heap." These are implementation details that vary by JavaScript engine.
The fundamental difference between primitives and objects is mutability:
This distinction explains ALL the behavioral differences you'll encounter.
The 7 primitive types behave as if each variable has its own independent copy:
| Type | Example | Key Behavior |
|---|---|---|
string | "hello" | Immutable — methods return NEW strings |
number | 42 | Immutable — arithmetic creates NEW numbers |
bigint | 9007199254740993n | Immutable — operations create NEW BigInts |
boolean | true | Immutable |
undefined | undefined | Immutable |
null | null | Immutable |
symbol | Symbol("id") | Immutable AND has identity |
Key characteristics:
let greeting = "hello";
let shout = greeting.toUpperCase();
console.log(greeting); // "hello" — unchanged!
console.log(shout); // "HELLO" — new string
Everything that's not a primitive is an object:
| Type | Example | Key Behavior |
|---|---|---|
| Object | { name: "Alice" } | Mutable — properties can change |
| Array | [1, 2, 3] | Mutable — elements can change |
| Function | function() {} | Mutable (has properties) |
| Date | new Date() | Mutable |
| Map | new Map() | Mutable |
| Set | new Set() | Mutable |
Key characteristics:
Think of objects like houses and variables like keys to those houses:
Primitives (like writing a note): You write "42" on a sticky note and give a copy to your friend. You each have independent notes. If they change theirs to "100", your note still says "42".
Objects (like sharing house keys): Instead of giving your friend the house itself, you give them a copy of your house key. You both have keys to the SAME house. If they rearrange the furniture, you'll see it too — because it's the same house!
┌─────────────────────────────────────────────────────────────────────────┐
│ PRIMITIVES vs OBJECTS: THE KEY ANALOGY │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ PRIMITIVES (Independent Notes) OBJECTS (Keys to Same House) │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ a = "42" │ │ x = 🔑 ─────────────┐ │
│ └─────────────┘ └─────────────┘ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ b = "42" │ (separate copy) │ y = 🔑 ─────────►│ 🏠 │ │
│ └─────────────┘ └─────────────┘ │ {name} │ │
│ └──────────┘ │
│ Change b to "100"? Change the house via y? │
│ a stays "42"! x sees the change too! │
│ │
└─────────────────────────────────────────────────────────────────────────┘
The key insight: it's not about where the key is stored, it's about what it points to.
Here's where most tutorials get it wrong. JavaScript doesn't use "pass by value" OR "pass by reference." It uses a third strategy called call by sharing (also known as "call by object sharing").
<Info> **Call by sharing** was first described by Barbara Liskov for the CLU programming language in 1974. JavaScript, Python, Ruby, and Java all use this evaluation strategy. </Info>When you pass an argument to a function, JavaScript:
| Operation | Does it affect the original? |
|---|---|
Mutating properties (obj.name = "Bob") | ✅ Yes — same object |
Reassigning the parameter (obj = newValue) | ❌ No — only rebinds locally |
When you modify an object through a function parameter, the original object is affected:
function rename(person) {
person.name = "Bob"; // Mutates the ORIGINAL object
}
const user = { name: "Alice" };
rename(user);
console.log(user.name); // "Bob" — changed!
What happens in memory:
BEFORE rename(user): INSIDE rename(user):
┌────────────┐ ┌────────────┐
│user = 🔑 ──┼──► { name: │user = 🔑 ──┼──► { name: "Bob" }
└────────────┘ "Alice" } ├────────────┤ ▲
│person= 🔑 ─┼───────┘
└────────────┘ (same house!)
If you reassign the parameter to a new object, it only changes the local variable:
function replace(person) {
person = { name: "Charlie" }; // Creates NEW local reference
}
const user = { name: "Alice" };
replace(user);
console.log(user.name); // "Alice" — unchanged!
What happens in memory:
INSIDE replace(user):
┌────────────┐ ┌─────────────────┐
│user = 🔑 ──┼───►│ { name: "Alice" }│ ← Original, unchanged
├────────────┤ └─────────────────┘
│person= 🔑 ─┼───►┌───────────────────┐
└────────────┘ │ { name: "Charlie" }│ ← New object, local only
└───────────────────┘
Here's the mind-bending part: primitives are also passed by sharing. You just can't observe it because primitives are immutable — there's no way to mutate them through the parameter.
function double(num) {
num = num * 2; // Reassigns the LOCAL variable
return num;
}
let x = 10;
let result = double(x);
console.log(x); // 10 — unchanged (reassignment doesn't affect original)
console.log(result); // 20 — returned value
The same "reassignment doesn't work" rule applies to primitives. It's just that with primitives, there's no mutation to try anyway!
This is where bugs love to hide.
When you copy a primitive, they behave as completely independent values:
let a = 10;
let b = a; // b gets an independent copy
b = 20; // changing b has NO effect on a
console.log(a); // 10 (unchanged!)
console.log(b); // 20
When you copy an object variable, you copy the reference. Both variables now point to the SAME object:
let obj1 = { name: "Alice" };
let obj2 = obj1; // obj2 gets a copy of the REFERENCE
obj2.name = "Bob"; // modifies the SAME object!
console.log(obj1.name); // "Bob" (changed!)
console.log(obj2.name); // "Bob"
Arrays are objects too, so they behave the same way:
let arr1 = [1, 2, 3];
let arr2 = arr1; // arr2 points to the SAME array
arr2.push(4); // modifies the shared array
console.log(arr1); // [1, 2, 3, 4] — Wait, what?!
console.log(arr2); // [1, 2, 3, 4]
Two primitives are equal if they have the same value:
let a = "hello";
let b = "hello";
console.log(a === b); // true — same value
let x = 42;
let y = 42;
console.log(x === y); // true — same value
Two objects are equal only if they are the SAME object (same reference):
let obj1 = { name: "Alice" };
let obj2 = { name: "Alice" };
console.log(obj1 === obj2); // false — different objects!
let obj3 = obj1;
console.log(obj1 === obj3); // true — same reference
console.log({} === {}); // false — two different empty objects
console.log([] === []); // false — two different empty arrays
console.log([1,2] === [1,2]); // false — two different arrays
// Simple (but limited) approach
JSON.stringify(obj1) === JSON.stringify(obj2)
// For arrays of primitives
arr1.length === arr2.length && arr1.every((v, i) => v === arr2[i])
// For complex cases, use a library like Lodash
_.isEqual(obj1, obj2)
Caution with JSON.stringify: Property order matters! {a:1, b:2} and {b:2, a:1} produce different strings. It also fails with undefined, functions, Symbols, circular references, NaN, and Infinity.
</Tip>
Symbols are primitives but have identity — two symbols with the same description are NOT equal:
const sym1 = Symbol("id");
const sym2 = Symbol("id");
console.log(sym1 === sym2); // false — different symbols!
console.log(sym1 === sym1); // true — same symbol
Understanding this distinction is crucial for avoiding bugs.
Mutation modifies the existing object in place:
const arr = [1, 2, 3];
// These are all MUTATIONS:
arr.push(4); // [1, 2, 3, 4]
arr[0] = 99; // [99, 2, 3, 4]
arr.pop(); // [99, 2, 3]
arr.sort(); // modifies in place
const obj = { name: "Alice" };
// These are all MUTATIONS:
obj.name = "Bob"; // changes property
obj.age = 25; // adds property
delete obj.age; // removes property
Reassignment makes the variable point to something else entirely:
let arr = [1, 2, 3];
arr = [4, 5, 6]; // REASSIGNMENT — new array
let obj = { name: "Alice" };
obj = { name: "Bob" }; // REASSIGNMENT — new object
const Trapconst prevents reassignment but NOT mutation:
const arr = [1, 2, 3];
// ✅ Mutations are ALLOWED:
arr.push(4); // works!
arr[0] = 99; // works!
// ❌ Reassignment is BLOCKED:
arr = [4, 5, 6]; // TypeError: Assignment to constant variable
const obj = { name: "Alice" };
// ✅ Mutations are ALLOWED:
obj.name = "Bob"; // works!
obj.age = 25; // works!
// ❌ Reassignment is BLOCKED:
obj = { name: "Eve" }; // TypeError: Assignment to constant variable
Object.freeze()If you need a truly immutable object, use Object.freeze():
const user = Object.freeze({ name: "Alice", age: 25 });
user.name = "Bob"; // Silently fails (or throws in strict mode)
user.email = "[email protected]"; // Can't add properties
delete user.age; // Can't delete properties
console.log(user); // { name: "Alice", age: 25 } — unchanged!
const user = Object.freeze({
name: "Alice",
address: { city: "NYC" }
});
user.name = "Bob"; // Blocked
user.address.city = "LA"; // Works! Nested object not frozen
console.log(user.address.city); // "LA"
For deep freezing, you need a recursive function or use structuredClone() to create a deep copy first.
When you need a truly independent copy of an object, you have two options.
A shallow copy creates a new object with copies of the top-level properties. But nested objects are still shared!
const original = {
name: "Alice",
address: { city: "NYC" }
};
// Shallow copy methods:
const copy1 = { ...original }; // Spread operator
const copy2 = Object.assign({}, original); // Object.assign
// Top-level changes are independent:
copy1.name = "Bob";
console.log(original.name); // "Alice" ✅
// But nested objects are SHARED:
copy1.address.city = "LA";
console.log(original.address.city); // "LA" 😱
A deep copy creates completely independent copies at every level.
const original = {
name: "Alice",
scores: [95, 87, 92],
address: { city: "NYC" }
};
// structuredClone() — the modern way (ES2022+)
const deep = structuredClone(original);
// Now everything is independent:
deep.address.city = "LA";
console.log(original.address.city); // "NYC" ✅
deep.scores.push(100);
console.log(original.scores); // [95, 87, 92] ✅
The ECMAScript specification defines behavior, not implementation. It never mentions "stack" or "heap." Different JavaScript engines can store values however they want, as long as the behavior matches the spec.
V8 (Chrome, Node.js, Deno) uses a technique called pointer tagging to efficiently represent values. According to the V8 team's blog, this optimization is critical for JavaScript performance — it allows the engine to distinguish small integers from heap pointers without additional memory lookups.
The ONLY values V8 stores "directly" (not on the heap) are Smis — Small Integers in the range approximately -2³¹ to 2³¹-1 (about -2 billion to 2 billion).
┌─────────────────────────────────────────────────────────────────────────┐
│ V8 POINTER TAGGING │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Smi (Small Integer): │
│ ┌────────────────────────────────────────────────────────────┬─────┐ │
│ │ Integer Value (31 bits) │ 0 │ │
│ └────────────────────────────────────────────────────────────┴─────┘ │
│ Tag bit │
│ │
│ Heap Pointer (everything else): │
│ ┌────────────────────────────────────────────────────────────┬─────┐ │
│ │ Memory Address │ 1 │ │
│ └────────────────────────────────────────────────────────────┴─────┘ │
│ Tag bit │
│ │
└─────────────────────────────────────────────────────────────────────────┘
This includes values you might think are "simple":
| Value Type | Where It's Stored | Why |
|---|---|---|
| Small integers (-2³¹ to 2³¹-1) | Directly (as Smi) | Fixed size, fits in pointer |
| Large numbers | Heap (HeapNumber) | Needs 64-bit float |
| Strings | Heap | Dynamically sized |
| BigInts | Heap | Arbitrary precision |
| Objects, Arrays | Heap | Complex structures |
V8 optimizes identical strings by potentially sharing memory (string interning). Two variables with the value "hello" might point to the same memory location internally. But this is an optimization — strings still behave as independent values because they're immutable.
The simplified stack/heap model is useful for understanding behavioral differences:
Just know it's a mental model for behavior, not how JavaScript actually works internally.
<Tip> **Want to go deeper?** Check out our [JavaScript Engines](/concepts/javascript-engines) guide for more on V8 internals, JIT compilation, and optimization. </Tip>const myUsers = [{ name: "Alice" }];
processUsers(myUsers);
console.log(myUsers); // [{ name: "Alice" }, { name: "New User" }]
// FIX: Create a copy first
function processUsers(users) {
const copy = [...users];
copy.push({ name: "New User" });
return copy;
}
```
// These RETURN a new array (safe):
arr.map() arr.filter()
arr.slice() arr.concat()
arr.flat() arr.flatMap()
arr.toSorted() arr.toReversed() // ES2023
arr.toSpliced() // ES2023
// GOTCHA: sort() mutates!
const nums = [3, 1, 2];
const sorted = nums.sort(); // nums is NOW [1, 2, 3]!
// FIX: Copy first, or use toSorted()
const sorted = [...nums].sort();
const sorted = nums.toSorted(); // ES2023
```
// Even these fail:
[] === [] // false
{} === {} // false
[1, 2] === [1, 2] // false
// FIX: Compare contents
JSON.stringify(a) === JSON.stringify(b) // Simple but limited
// Or use a deep equality function/library
```
const copy = { ...user };
copy.settings.theme = "light";
console.log(user.settings.theme); // "light" — Original changed!
// FIX: Use deep copy
const copy = structuredClone(user);
```
original.push(4);
console.log(backup); // [1, 2, 3, 4] — "backup" changed!
// FIX: Actually copy the array
const backup = [...original];
const backup = original.slice();
```
const myArr = [1, 2, 3];
clearArray(myArr);
console.log(myArr); // [1, 2, 3] — unchanged!
// FIX: Mutate instead of reassign
function clearArray(arr) {
arr.length = 0; // Mutates the original
}
```
Treat objects as immutable when possible
// Instead of mutating:
user.name = "Bob";
// Create a new object:
const updatedUser = { ...user, name: "Bob" };
Use const by default — prevents accidental reassignment
Know which methods mutate
push, pop, sort, reverse, splicemap, filter, slice, concat, toSortedUse structuredClone() for deep copies
const clone = structuredClone(original);
Clone function parameters if you need to modify them
function processData(data) {
const copy = structuredClone(data);
// Now safe to modify copy
}
Be explicit about intent — comment when mutating on purpose
</Tip>Primitives vs Objects — the ECMAScript terms (not "value types" vs "reference types")
The real difference is mutability — primitives are immutable, objects are mutable
Call by sharing — JavaScript passes ALL values as copies of references; mutation works, reassignment doesn't
Object identity — objects are compared by identity, not content ({} === {} is false)
const prevents reassignment, not mutation — use Object.freeze() for true immutability
Shallow copy shares nested objects — use structuredClone() for deep copies
Know your array methods — push/pop/sort mutate; map/filter/slice don't
The stack/heap model is a simplification — useful for understanding behavior, not technically accurate
In V8, only Smis are stored directly — strings, BigInts, and objects all live on the heap
Symbols have identity — two Symbol("id") are different, unlike other primitives
- **Primitives are immutable** — you cannot change a primitive value, only replace it. Copies behave independently.
- **Objects are mutable** — you CAN change an object's contents. Multiple variables can point to the same object.
The distinction is about **mutability**, not storage location.
**Answer:** `5`
Both `a` and `b` point to the same object. When you modify `b.count`, you're modifying the shared object, which `a` also sees. This is because **mutation affects the shared object**.
Each `{}` creates a NEW empty object in memory. Even though they have the same contents (both empty), they are different objects.
```javascript
{} === {} // false (different objects)
const a = {};
const b = a;
a === b // true (same object)
```
- **Call by sharing:** Function receives a copy of the reference. Mutation works, but reassignment only changes the local parameter.
- **Pass by reference (C++ style):** Parameter is an alias for the argument. Reassignment WOULD change the original.
JavaScript uses call by sharing. That's why this doesn't work:
```javascript
function replace(obj) {
obj = { new: "object" }; // Only changes local parameter
}
let x = { old: "object" };
replace(x);
console.log(x); // { old: "object" } — unchanged!
```
`const` only prevents **reassignment** — you can't make the variable point to a different value. But you CAN still **mutate** the object's contents.
```javascript
const obj = { name: "Alice" };
obj.name = "Bob"; // ✅ Allowed (mutation)
obj.age = 25; // ✅ Allowed (mutation)
obj = {}; // ❌ Error (reassignment)
```
Use `Object.freeze()` for true immutability.
In V8, **only Smis (small integers)** are stored directly. Strings are dynamically-sized and stored on the heap. The variable holds a pointer to the string's location in heap memory.
The "stack vs heap" model is a **mental model for behavior**, not how JavaScript actually works.
- **Shallow copy** creates a new object but shares nested objects
- **Deep copy** creates independent copies at ALL levels
```javascript
const original = { nested: { value: 1 } };
// Shallow: nested is shared
const shallow = { ...original };
shallow.nested.value = 2;
console.log(original.nested.value); // 2 (affected!)
// Deep: completely independent
const deep = structuredClone(original);
deep.nested.value = 3;
console.log(original.nested.value); // 2 (unchanged)
```