Back to the Primitive!
A dive into JavaScript's memory model, exploring how primitives and objects are stored differently in stack and heap memory, the magic of autoboxing that lets primitives behave like objects, and the conversion mechanisms that allow objects to act like primitives.
Back to the Primitive!
JavaScript variables can be categorized as objects and primitives. The key distinction between them is that the value is stored differently. Most primitives' values are stored directly on the stack, while object variables only keep their references on the stack, but the actual values are located in the heap. However, it's not that straightforward and might sometimes be confusing.
What is Stack and what is Heap again?
The stack and heap are two different memory regions JavaScript engines use to store data. To make it clearer, let's look at the steps performed by the compiler when a new variable is created, for example: let value = 65;
:
- Code parsing.
- Binary Encoding:
The number 65 in binary is01000001
. - Memory Storage:
This sequence of 1s and 0s is stored in a specific location in computer's memory. Each bit (a 1 or a 0) is physically represented by the state of a tiny transistor (e.g., high voltage for 1, low voltage for 0). - The Variable as a Label:
The initial of the variable name acts as a human-readable label or pointer that the JavaScript engine uses to remember the memory address where the value01000001
is stored. - Type Definition Meta:
The JavaScript engine stores the value and metadata representing the type. Instead of using the entire 64-bit (or 32-bit) space for a variable's value, the engine reserves the last bits for a "type tag."
So, the variable initially holding the value of 65 is ultimately just a named reference to a memory location containing a pattern of high and low electrical voltages that represent the binary code for the number 65.
Now, when we have our precious zeros and ones, we can put them somewhere. Here is where the Stack and Heap come into play.
It's not that there are physically separate "stack" and "heap" chips in your computer. Instead, they are logical structures created within the same pool of physical RAM, managed by the OS.
Stack Memory
The stack is a highly structured, contiguous block of memory. Its organization is what makes it so fast. The key point is that values have a fixed size. It's structure defines principles of how the stack operates:
- LIFO Principle: It operates strictly on a Last-In, First-Out (LIFO) basis.
- The Stack Pointer: The CPU has a special, very fast register called the Stack Pointer. This pointer always holds the memory address of the "top" of the stack.
- Growth: When a function is called, its execution context (including local primitive variables and references to objects) is "pushed" onto the stack. This is a simple, lightning-fast operation where the stack pointer is just moved down to allocate space, and the data is written.
- Shrinking: When the function returns, its context is "popped" off. The stack pointer simply moves back up. The data isn't erased; it's just considered free space that gets overwritten by the next push, leading to incredibly efficient stack operations.
It is worth mentioning that frequently used values are always persisting on the stack due to optimizations, so that the engine does not recreate them. These values change over time and may vary for the different JavaScript engines. These values include:
- Numbers: A large range of integers (depending on the engine;
-2^31
to2^31 - 1
for V8) - Strings: All single-character strings (
'a'
,'b'
, ...), the empty string (''
), - Booleans:
true
andfalse
. - Special Values:
null
andundefined
.
So you might be writing a highly optimized code without even taking care of it!
Heap Memory
The heap is the opposite of the stack: it's a large, unstructured region of memory used for dynamic allocation. Unlike the stack's organization and predictable structure, the heap operates with entirely different principles that make it both more flexible and more complex to manage.
- No Order: It's just a big pool of memory. There is no LIFO principle. Data can be stored and removed in any order.
- Dynamic Allocation: When your code needs to create an object (e.g.,
let person = {}
), the JavaScript engine requests a block of memory from the heap. The memory manager finds a free block of the required size, reserves it, and returns a pointer (the memory address) to that block. This pointer is what gets stored in theperson
-variable on the stack. - Slower Access: This process is inherently slower than a stack push because the memory manager has to perform work to find a suitable free block.
- Garbage Collection: Because there's no automatic cleanup like a stack pop, the JavaScript engine must periodically run a process called the Garbage Collection (GC). The job of GC is to go through the heap, identify objects that are no longer referenced by any part of the program, and mark their memory as free. This is a complex operation that adds overhead.
Difference in variable storage
The values in following example will be stored differently:
let age = 25; // Primitive stored in stack
let person = { // Reference stored in stack
name: "John", // Object data stored in heap
age: 25
};
Using the declared variables from the example above, and assigning them to other variables, the following happens:
- Initializing a new variable with
age
(e.g.,let anotherAge = age
), the actual value ofage
is copied. - Whereas, initializing a new variable and assigning it
person
(e.g,let anotherPerson = person
), only the reference to theperson
is copied and not the object itself. This would mean that modifyinganotherPerson.name
also affectsperson.name
as both point to the same object in heap memory.
const person = {
name: "Peter",
surname: "Falcone"
};
const anotherPerson = person; // Reference to the same object is copied to new variable
anotherPerson.name = "David"; // Changing property of the object on heap
console.log(person.name); // Outputs "David"
It might be quiet frustrating to find an error, that was caused by changing values by the reference. Such situations might be easily avoided by using ...
(spread) operator:
const person = {
name: "Peter",
surname: "Falcone"
};
const anotherPerson = {
...person,
name: "David"
};
console.log(person.name); // Outputs "Peter"
Strings are Tricky
If the value on the stack is a single number that represents an actual value or reference, then how are strings saved? For representing strings, we need several numbers!
This question, was confusing me for quiet a time. Strings are actually stored on the heap, and technically should not be primitive, but they are because of the JavaScript design, that make all strings immutable.
To store string the JavaScript engine allocates a block of memory on the heap and binds it to the value on stack. This heap-block is not a standard JavaScript object. It's a raw, internal data structure that contains the number of characters (e.g., 2) and character Data: A sequence of 16-bit integers representing the UTF-16 codes for each character (72
for 'H'
, 105
for 'i'
).
But... but everything is object in the JavaScript (my grandma said)
You might have heard that everything is object in JavaScript and it also supported by the fact, that primitives can have their methods accessible via .
call. How does it play together? How the zeros and ones from the stack can have methods?
The magic that allows you to call methods on them is a process called autoboxing (or creating a "primitive wrapper object").
Here’s what happens behind the scenes when you execute code like let name = "John"; name.toUpperCase();
:
Property Access Detected: The JavaScript engine sees that you are trying to access a property (
.toUpperCase
) on a primitive value (name, which holds the string "John").Implicit Wrapper Creation: The engine knows this is impossible for a raw primitive. So, it instantly and temporarily creates a wrapper object of the corresponding type. In this case, it does the equivalent of new String("John"). This temporary object is created on the heap.
Method Execution: This newly created String object has access to all the methods defined on
String.prototype
, includingtoUpperCase()
. The engine now calls thetoUpperCase()
method on this temporary object.Return the Primitive: The method executes and returns a new primitive value, the string "JOHN".
Discard the Wrapper: The temporary wrapper object has served its purpose and is immediately discarded. It becomes eligible for garbage collection. The engine doesn't keep it around.
// 1. You write this: let name = "John".toUpperCase(); // 2. The JavaScript engine effectively does this: // a. Create a temporary wrapper object at the time // of property access let tempWrapper = new String("John"); // b. Call the method on the wrapper let result = tempWrapper.toUpperCase(); // result is the primitive "JOHN" // c. Discard the temporary wrapper tempWrapper = null; // (now eligible for garbage collection) // d. Assign the final primitive result let upper = result; // upper is "JOHN"
So you (and my grandma) weren't that wrong.
Wrapper constructor as instances
The JavaScript engine allows using these wrapper constructors in the code. For example:
const str = new String('str');
But be careful as the provided values are no longer primitive (which also forces then to loose their falsy status):
// String example
const johnString = new String('John');
johnString.surname = 'Connor';
console.log(johnString.surname); // outputs 'Connor`
// Loosing the falsy status
const isFalsy = new Boolean(false);
if (isFalsy) {
console.log('this will run');
}
In most cases using wrapper constructors for creating instances is treated as a bad practice.
What is useful though, is to use wrapper constructors as functions to convert variables:
const values = ['', 'John', 1, 12, {}, null];
const truthyValues = values.filter(Boolean);
console.log(truthyValues); // outputs ['John', 1, 12, {}]
.toPrimitive
conversion magic happening implicitly
What if I tell that not only primitives acting like objects, but also vice versa?
const myPrimitiveObject = {
[Symbol.toPrimitive]: () => 101
};
const sum = 606 + myPrimitiveObject;
console.log(sum); // Outputs 707
Whenever JavaScript operator expects a primitive but receives an object, a mechanism of a to-primitive conversion will be called; in the example above, the [Symbol.toPrimitive]
represents it. [Symbol.toPrimitive]
is a quite powerful mechanism offering a wide flexibility. This function receives automatically a context (a.k.a hint), which can be used for a more sophisticated logic.
const myPrimitiveObject = {
[Symbol.toPrimitive]: (hint) => {
if (hint === 'string') return 'Hey';
if (hint === 'number') return 101;
return null;
},
};
console.log(`${myPrimitiveObject}, John!$`); // Outputs "Hey, John!"
console.log(808 - myPrimitiveObject); // Outputs 707
Here is the reference how the hint will be evaluated from the context:
Operation | Hint Sent to [Symbol.toPrimitive] |
---|---|
+ (binary operator) | "default" |
== , != | "default" |
- , * , / , % , ** | "number" |
> , < , >= , <= | "number" |
+ (unary operator) | "number" |
String(obj) , ${obj} | "string" |
Number(obj) | "number" |
Using object as a property key | "string" |
It is important to know that [Symbol.toPrimitive]
has a priority over the toString
and valueOf
methods, used to convert to primitive before ES6 was released. Let's check following example:
const toPrimitive = () => 1;
const valueOf = () => 2
const toString = () => 3;
const a = { [Symbol.toPrimitive]: toPrimitive, valueOf, toString };
const b = { valueOf, toString };
const c = { toString };
console.log(1 == a); // Outputs true
console.log(2 == b); // Outputs true
console.log(3 == c); // Outputs true
Please note that valueOf
not always has priority on the toString
In most cases when object needs to have a primitive expression it is better to use more explicit logic with [Symbol.toPrimitive]
There are several cases when the primitive representation of an object will be called:
- usage of non-strict comparison operators with
>
,<
,>=
,<=
and==
, - call of
parseInt
andparseFloat
, - accessing object's properties like
obj[primitiveObject]
, - usage of arithmetic operators
The +
binary operator requires a specific attention because it also historically performs strings concatenation. The following rules are taking place:
- if either operand is primitive
String
, then it'll concatenate both operands together as strings, - if neither operand is primitive
String
, then it'll add both operands as numbers (with conversion viaNumber()
function). - if either operand is non-primitive, then it will be converted to
String
beforehand
Example:
123 + 'a'; // Outputs '123a'
'a' + 123; // Outputs 'a123'
[123] + 123; // Outputs '123123' because [123].toString() === '123'
Conclusion
Understanding the distinction between primitives and objects in JavaScript reveals the fundamental architecture of memory management. While primitives store immutable values directly, objects store references pointing to heap memory where the actual data lives. This explains why primitive assignments create copies while object assignments share references.
The JavaScript engine's clever autoboxing mechanism allows primitives to temporarily behave like objects when accessing methods, creating wrapper objects on-demand before discarding them. Conversely, objects can be converted to primitives through Symbol.toPrimitive
, valueOf
, or toString
methods when used in contexts expecting primitive values.
This dual nature makes JavaScript both powerful and sometimes surprising, requiring developers to understand when they're working with values versus references.
Published by...