While debugging, I found some code that took an object, then returned a modified version of that object. To be fair, the code was mutating state in a reducer, but that's an issue for a separate day - for organizational reasons, refactoring needs to wait until November.
The Problem
The following basic example is functionally similar to the code I encountered, which was working as expected:
1
let fxn = (state) => {
2
3
// incoming state looked like the following:
4
// collection: [ a, b, c ]...
5
// // and more
6
// }
7
8
return Object.assign(state, { collection: [] })
9
}
Attempting to use other object cloning syntax caused errors elsewhere in our application...
1
let fxn = (state) => {
2
return Object.assign({}, state.testObject, { collection: [] });
3
}
Spread syntax caused the same error:
1
let fxn = (state) => {
2
return { ... state.testObject, collection: [] );
3
}
If you're unfamiliar with spread syntax as used here, it should return a shallow clone of the above object. Here's the question I needed to answer: what's the difference between passing an object as the first argument of Object.assign()
, versus having passing that same object as the second argument, or using the spread syntax?
Let's dive into the documentation. In Object.assign()
, the first argument is called the target, and subsequent arguments are called sources.
The Object.assign() method only copies enumerable and own properties from a source object to a target object. It uses [[Get]] on the source and [[Set]] on the target, so it will invoke getters and setters. Therefore it assigns properties versus just copying or defining new properties. This may make it unsuitable for merging new properties into a prototype if the merge sources contain getters.
Emphasis added - if you need a refresh on (or introduction to) getters, see the MDN getter documentation. The stage 4 spread syntax proposal describes something similar:
Spread properties in object initializers copies own enumerable properties from a provided object onto the newly created object.
Let's explore some example code that demonstrates the differences in regards to getters and setters.
Exploring the Problem
First, let's set up an object with a getters, implementing a very simple stack.
1
let stack = {
2
storage: [1, 2, 3],
3
push: function(x) { return this.storage.push(x) },
4
pop: function() { return this.storage.pop() },
5
get peek() { return this.storage[this.storage.length - 1] }
6
}
7
8
console.log(stack.peek) // logs 3
Simple enough! Now, let's try and (shallowly) clone this object using the first non-working example. First:
1
let secondStack = Object.assign({}, stack);
2
secondStack.push(4);
3
console.log(secondStack.peek) // logs 3 (!)
Why does peek return 3? Because secondStack
's properties now looks like this:
1
{ storage: [ 1, 2, 3, 4 ],
2
push: [Function: push],
3
pop: [Function: pop],
4
peek: 3 }
Why? Remember that the documentation for Object.assign()
says that it uses [[Get]]
on the source and [[Set]]
on the target, so for source objects (e.g., the second argument and beyond) invokes getters and setters instead of copying them. Object.assign()
has replaced getter peek()
with the result of the getter at assignment, or 3.
A reminder that this is a shallow clone:
1
console.log(stack.push === secondStack.push) // true
2
console.log(stack.pop === secondStack.pop) // true
3
console.log(stack.peek === secondStack.peek) // false
Here's what the second non-working example looked like:
1
let thirdStack = { ... stack }
2
thirdStack.push(4);
3
console.log(thirdStack.peek) // logs 3 (!)
thirdStack
's properties now look the same as Object.assign({}, stack)
:
1
{ storage: [ 1, 2, 3, 4 ],
2
push: [Function: push],
3
pop: [Function: pop],
4
peek: 3 }
The documentation isn't explicit about this: it returned the the value of the getter at assignment, rather than the getter itself.
Demonstrating the Same Behavior with Setters
With a new example, too!
The difference in behavior for setters is identical. I'm just going to start with a new example, straight from the setter MDN page for simplicity.
1
var language = {
2
set current(name) {
3
this.log.push(name);
4
},
5
log: []
6
}
7
8
language.current = 'EN';
9
language.current = 'FA';
10
11
console.log(language.log); // [ 'EN', 'FA' ]
Let's use it as the source argument in Object.assign()
:
1
let firstLanguage = Object.assign(language, {})
2
// resulting object: { current: [Setter], log: [ 'EN', 'FA' ] }
The documentation says that 'It uses [[Get]] on the source and [[Set]] on the target, so it will invoke getters and setters.' As far as I can tell, I'd reword this as: the object returned from Object.assign()
will have the getters and setters of the target object, and will replace getters and setters on source objects with the the values returned at the invocation of Object.assign()
.
Moving on, if we use Object.assign()
with our language object as the source, the setter is invoked:
1
let secondLanguage = Object.assign({}, language);
2
// resulting object: { current: undefined, log: [ 'EN', 'FA' ] }
And lastly:
1
let thirdLanguage = { ... language };
2
// resulting object: { current: undefined, log: [ 'EN', 'FA' ] }
Lesson: cloning objects in Javascript is nuanced.
But my personal takeaway here has to do with the context in which I first encountered this problem. The actual state object was massive and deeply nested and thus our reducer was written with the wrong kind of store in mind: this could be mitigated by designing a state that was deeply cloned by the spread syntax or Object.assign()
, or structuring the store in such a way that a deep clone was simple to write, by flattening and reducing the size.