Clarifying “error-prone” nuance of spread syntax vs. assignment in Javascript

42 Views Asked by At

I know that the assignment of one variable to another is done by shared reference so that when you set var c = b; and then you do some change change to c such as c.cool = 'new'; then b gets affected as well. console.log(b.cool); // new

However, coming to Javascript from different programming languages, I remember there is an error prone ‘gotcha’ when using the spread syntax var c = {…b}. Can somebody clarify this ‘gotcha’ and any nuances to keep track of?

1

There are 1 best solutions below

0
On

TLDR; When var b = { …an object of your choice …}; the subsequent assignment of var c = b; is an assignment by shared reference. You can think of both b and c as pointing to the same thing in memory; things that change c will change b and vice-versa, change b and it will change c.

Assignment by spread syntax var c = {...b} means something slightly more complicated. Specifically, the behavior is different is for top-level properties and for nested properties. For top-level properties, there is a deep copy (leaving c and b are independent) but for nested-properties, there is a shallow copy (changes to c and b affect the other.) (SO DEEP COPY FOR TOP-LEVEL PROPERTIES, SHALLOW COPY FOR NESTED-PROPERTIES).



So what are SHALLOW COPY vs. DEEP COPY?

From free code camp:

A shallow copy creates a new object or array, but it only copies the references to the properties of the original object or array. In other words, the new object or array has the same values for its properties as the original, but the properties themselves still reference the same values in memory. This means that any changes made to the properties of the new object or array will also affect the original object or array, and vice versa.

A deep copy, on the other hand, creates a new object or array. It also copies the values of the properties of the original object or array, rather than just the references. This means that any changes made to the new object or array will not affect the original object or array, and vice versa.

So let’s look at an example and change both a top-level property and then a nested property. And remember, you can change c and see how to affects b, but you can also change b and see how it changes c.

REGULAR ASSIGNMENT

// VIA REGULAR ASSIGNMENT 
let b = { top1: 'zero', top2: 'zero', nested1: { inside1: 'zero' }, nested2: { inside2: 'zero'}};
let c = b; // Regular assignment

// Starting from C -> B
// change a top-level property
c.top1 = 'one';

// change the nested property
c.nested1.inside1 = 'one';

// let's look at the console for c and it's corresponding property for b
console.log(c.top1); // one
console.log(b.top1); // one

console.log(c.nested1.inside1); // one
console.log(b.nested1.inside1); // one

// You can also go the other way: B->C
// change a top-level property
b.top2 = 'two';

// change the nested property
b.nested2.inside2 = 'two';

// let's look at the console for b and it's corresponding property for c    
console.log(b.top2); //two
console.log(c.top2); //two

console.log(b.nested2.inside2); //two
console.log(c.nested2.inside2); //two

So far ^ that’s pretty consistent, anything you change in c gets reflected in b.

However, the following is the “gotcha” of spread syntax that ... well ... does not feel as clean from a programming language perspective, and that can be a bit user error prone.

When assigning a variable via spread syntax, a deep copy is made at the top-level properties, meaning you get NEW copies of top-level properties but only a shallow copy (SHARED REFERENCES) for any nested properties. Here is are example that shows the difference in how top-level vs nested properties are treated.

SPREAD SYNTAX

// VIA SPREAD SYNTAX 
let b = { top1: 'zero', top2: 'zero', nested1: { inside1: 'zero' }, nested2: { inside2: 'zero'}};
let c = { ...b }; // spread syntax — this is a deep copy at the top level, and shallow copy for nested properties

// Starting from C -> B
// change a top-level property
c.top1 = 'one';

// change the nested property
c.nested1.inside1 = 'one';

// let's look at the console for c and it's corresponding property for b
console.log(c.top1); // one
console.log(b.top1); // zero - NOTE: B’s top-level properties are unaffected

console.log(c.nested1.inside1); // one
console.log(b.nested1.inside1); // one - NOTE: B’s nested properties change

// You can also go the other way: B->C
// change a top-level property
b.top2 = 'two';

// change the nested property
b.nested2.inside2 = 'two';

// let's look at the console for b and it's corresponding property for c    
console.log(b.top2); //two
console.log(c.top2); //zero - NOTE: C’s top-level properties are unaffected

console.log(b.nested2.inside2); //two
console.log(c.nested2.inside2); //two - NOTE: C’s nested properties change

ADDING A COMPLETELY NEW NESTED PROPERTY

What happens you later ADD a completely new nested property to one or the other?

REGULAR ASSIGNMENT(ADDING A COMPLETELY NEW NESTED PROPERTY)

// VIA REGULAR ASSIGNMENT(ADDING A COMPLETELY NEW NESTED PROPERTY)
let b = { top1: 'zero', top2: 'zero', nested1: { inside1: 'zero' }, nested2: { inside2: 'zero'}};
let c = b; // Regular assignment

// Starting from C -> B
// change a top-level property
c.newNested = 'yup';
c.newNested.newInside = 'cannot do this';
c.newNested = { newInside2: 'yup - this works'};

console.log(c.newNested); // {"newInside2": "yup - this works"}
console.log(c.newNested.newInside) // undefined
console.log(c.newNested.newInside2) // yup - this works

console.log(b.newNested); // {"newInside2": "yup - this works"}
console.log(b.newNested.newInside); // undefined
console.log(b.newNested.newInside2); // yup - this works - NOTE! The new nested property on C does affect B. 

// Let’s do this the reverse way B -> C
// change a top-level property
c.newNested2 = 'yup';
c.newNested2.newInside = 'cannot do this';
c.newNested2 = { newInside2: 'yup - this works'};

console.log(b.newNested2); // {"newInside2": "yup - this works"}
console.log(b.newNested2.newInside); // undefined
console.log(b.newNested2.newInside2); // yup - this works 

console.log(c.newNested2); // {"newInside2": "yup - this works"}
console.log(c.newNested2.newInside) // undefined
console.log(c.newNested2.newInside2) // yup - this works - NOTE! The new nested property on B does affect C. 

SPREAD SYNTAX (ADDING A COMPLETELY NEW NESTED PROPERTY)

// VIA SPREAD SYNTAX (ADDING A COMPLETELY NEW NESTED PROPERTY)
let b = { top1: 'zero', top2: 'zero', nested1: { inside1: 'zero' }, nested2: { inside2: 'zero'}};
let c = {...b}; // Regular assignment

// Starting from C -> B
// change a top-level property
c.newNested = 'yup';
c.newNested.newInside = 'cannot do this';
c.newNested = { newInside2: 'yup - this works'};

console.log(c.newNested); // {"newInside2": "yup - this works"}
console.log(c.newNested.newInside) // undefined
console.log(c.newNested.newInside2) // yup - this works

console.log(b.newNested); // undefined - the error here makes it such that the new two errors will definitely occur
console.log(b.newNested.newInside); // Uncaught TypeError: Cannot read properties or undefined (reading ‘newInside’)
console.log(b.newNested.newInside2); // Uncaught TypeError: Cannot read properties or undefined (reading ‘newInside2’)

// Let’s do this the reverse way B -> C
// change a top-level property
b.newNested2 = 'yup';
b.newNested2.newInside = 'cannot do this';
b.newNested2 = { newInside2: 'yup - this works'};

console.log(b.newNested2); // {newInside2: ‘yup - this works’}
console.log(b.newNested2.newInside) // undefined
console.log(b.newNested2.newInside2) // yup - this works

console.log(c.newNested2); // undefined - the error here makes it such that the new two errors will definitely occur
console.log(c.newNested2.newInside); // Uncaught TypeError: Cannot read properties or undefined (reading ‘newInside’)
console.log(c.newNested2.newInside2); // Uncaught TypeError: Cannot read properties or undefined (reading ‘newInside2’)

COMPLICATING THINGS EVEN MORE - PASS-BY-VALUE (PRIMITIVE TYPES)

Just to make things slightly more confusing.
The above paradigm really only works when b is an object. If b is a primitive value (number, string, boolean, null, undefined, symbol), then it’s called ‘assign-by-value’ and the behavior of var c = b; means something different. c gets its own set of values. The examples below show the difference:

// BASE CASE
let b = 'zero';
let c = b;

console.log(c) // zero
console.log(b) // zero

// REASSIGN C AND SEE IF IT AFFECTS B
let b = 'zero'; 
let c = b; 
c = 'one'; 

console.log(c); // one
console.log(b); // zero - NOTE: b is not affected

Referenced:

https://www.freecodecamp.org/news/javascript-comparison-operators-how-to-compare-objects-for-equality-in-js/

https://javascript.info/object-copy

https://www.freecodecamp.org/news/javascript-assigning-values-vs-assigning-references/

https://medium.com/@naveenkarippai/learning-how-references-work-in-javascript-a066a4e15600#:~:text=On%20variable%20assignment%2C%20the%20scalar,at%20other%20variables%20or%20references