JavaScript does not have a “pass by reference vs. pass by value” distinction: the Value of More Accurate Mental Models.

Max Heiber
5 min readMar 18, 2023
arrows

I’ll show why the folklore that “JS objects are passed by reference, primitives are passed by value” has nothing to do with passing, objects, nor references and is in fact an illusion. “Objects-are-passed-by-reference” (OAPBR) is a theory/mental model. Like most theories, it should be judged on its contribution to understanding, prediction, control, and its cost in terms of complexity. I hope to use this example as evidence that a very-incorrect mental model:

  • Makes learning advanced features harder
  • Makes learning other programming languages harder
  • And wastes time. At the end I’ll make suggestions for avoiding very-incorrect mental models.

The Phenomenon to be Explained

To show what the “Objects are Passed by Reference” (OAPBR) theory seeks to explain, we’ll first need two definitions:

function plusNum(n1, n2) {
return n1 + n2;
}

function mutatingAdd(obj1, n) {
obj1.value = plusNum(obj1.value, n)
}

In the following, objectExample passes x to takeNum. takeNum adds 1, which has no effect on objectExample's x:

function takeNum(n /* number */) {
n = plusNum(n, 1);
console.log(n); // 2
}

function primitiveExample() {
let x = 1;
takeNum(x);
console.log(x); // 1
}

The OAPBR theorist would say that primitiveExample doesn't see the change to x in takeNum because primitives are passed by value. We can write superficially similar code using objects rather than a primitive (like number) and get what appears to be distinct behavior:

function mutateObj(obj /* {field: number} */) {
mutatingAdd(obj, 1);
console.log(obj.value); // 2
}

function objectExample() {
let x = {value: 1};
mutateObj(x);
console.log(x.value); // 2
}

The OAPBR theorist would say that objectExample sees the change to x in mutateObj because primitives are passed by value.

How the “Objects are Passed by Reference” (OAPBR) Theory Fails

Now I’ll show that the previous examples have nothing to do with passing values to functions by adapting the examples to not use functions:

function primitiveExample2() {
let x = 1;
let y = x;
y = y + 1;
console.log(y); // 2
console.log(x); // 1
}

function objectExample2() {
let x = { value: 1 };
let y = x;
y.value = y.value + 1;
console.log(y.value); // 2
console.log(x.value); // 2
}

So the “objects are pass by reference, primitives are pass by value” theory already fails to generalize to explain what happens when you assign values to variables. We can add epicycles to the OAPBR theory to explain this, but it gets worse. Not only does the phenomenon to be explained have nothing to do with passing (calling functions), it has nothing to do with objects. Here we see the so-called “pass-by-reference” behavior and “pass-by-value” behavior for the same value, pointed-to by y:

function combinedExample1() {
let x = Object.assign(1, { value: 100 });
let y = x;
mutatingAdd(y, 100);
console.log(y.value); // 200
console.log(x.value); // 200
y = plusNum(y, 1);
console.log(y + 0); // 2
console.log(x + 0); // 1
}

In the terminology of the OAPBR theorist, y passed both by reference and by value. So is it an object or a primitve? The example involves some wonky JS features which I won't go into, but, as I claimed, a very-incorrect mental model can make it harder to understand advanced features. A believer in OAPBR at this point may add more epicycles be very excited about discovering the JavaScript equivalent of wave-particle duality and start adding epicycles to their theory. But we can save the OAPBR theorist time by showing that the "by-reference vs by-value" psuedo-distinction has nothing to do with objects-vs-primitives using a simpler example. Here x and y both point to plain JavaScript objects, yet we observe only the "by value" behavior:

function nonMutatingPlusObj(obj1, obj2) {
return { value: obj1.value + obj2.value };
}

function nonMutatingObjectExample() {
let x = {value: 100};
let y = x;
y = nonMutatingPlusObj(y, {value: 100})
console.log(y.value); // 200
console.log(x.value); // 100
}

Finally, I said one of the costs of bad mental models is that they make learning other programming languages harder. The “objects are pass by reference, primitives are pass by value” theory makes a mystery of the following PHP code. Because in the PHP code, for the first time in this post, something is actually passed by reference. And that thing is an integer:

<?php

function add_mut(&$i1, $i2) {
$i1 += $i2;
}

$x = 1;
add_mut($x, 1);
echo $x; // 2
?>

That’s because PHP has actual pass-by-reference but JS does not have a pass-by-reference feature. The OAPBR theory also stumbles on making sense of this Rust, where a reference to an integer is passed to add_mut;

fn add_mut(i: &mut i32, i2: i32) {
*i = (*i) + i2;
}

fn main() {
let i = &mut 1;
add_mut(i, 1);
println!("{}", i); // 2
}

Rust has built-in source-code-level references, but JS does not. You can make your own references in JS, though:

function makeRef(thing) {
return {value: thing};
}

function addMut(iRef, i2) {
iRef.value = iRef.value + i2;
}

let iRef = makeRef(1);
addMut(iRef, 1);
console.log(iRef.value); // 2

In fact, this is how React refs work.

A better theory

Instead of “Objects are passed by reference, primitives are passed by value”, I suggest a better distinction: between mutating a scope and mutating a value. In JS, scopes are mutable mappings from variable names to values.


let x = 1; // the scope maps x to 1
x = 2; // the scope maps x to 2
x += 1; // the scope maps x to 3

In the code above, no number is mutated (You can’t mutate numbers in JS), but the scope changes.

The “pass by reference” illusion probably arises in the context of functions because each function introduces a new scope. More on scopes in MDN.

In the code below, x and y point to the same object, which mutateObj mutates. mutatingAdd does not mutate the scope, since in JavaScript functions cannot mutate scope they don't close over:

function objectExample() {
let x = {value: 1};
let y = x;
mutatingAdd(y, 1);
console.log(y.value); // 2
console.log(x.value); // 2
}

That's it! "mutating scope vs mutating objects" is a much simpler distinction than "objects are pass by reference, primitives are pass by value", and the knowledge gained transfers better to other language features and other programming languages.

For JS, specifically, understanding that scope is mutable is essential for understanding const-vs-let, shadowing, object identity, closures, and writing UIs. And it's such a simple concept compared to the incorrect “by-value vs. by-reference” distinction.

What’s next

I’ve seen bad mental models for programming languages come from these sources:

  • Not using any learning resources, but instead diving in and trying to program. This may feel fast, but I think it’s the slow route in the long run.
  • Learning from blog posts or videos. (I know this is a blog post: trust MDN more than anything here.)
  • Assuming programming language features are obvious. Nothing is obvious, especially not variables, and there is surprising variation between and within programming languages for how things work.

What’s worked well for me, depending on the language, is reading the official programming language docs or a good book, and/or the spec. (JS is a special case where MDN is the best readable and accurate resource). Forming a more accurate mental model is a time-saver compared to the alternatives.

Happy hacking!

--

--