Skip to content

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;:

  1. Code parsing.
  2. Binary Encoding:
    The number 65 in binary is 01000001.
  3. 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).
  4. 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 value 01000001 is stored.
  5. 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 to 2^31 - 1 for V8)
  • Strings: All single-character strings ('a', 'b', ...), the empty string (''),
  • Booleans: true and false.
  • Special Values: null and undefined.

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 the person-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 of age is copied.
  • Whereas, initializing a new variable and assigning it person (e.g, let anotherPerson = person), only the reference to the person is copied and not the object itself. This would mean that modifying anotherPerson.name also affects person.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, including toUpperCase(). The engine now calls the toUpperCase() 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:

OperationHint 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 and parseFloat,
  • 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 via Number() 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...

Image of the author

Max O.

Visit author page