Object.assign
is the standard way to copy properties from one object to another. It is often used for copying properties that are one-layer deep. (One-layer deep means there are no nested objects).
It can be used to extend settings from a default object. Here's an example:
const one = { one: 'one' }
const two = { two: 'two' }
const merged = Object.assign({}, one, two)
console.log(merged) // { one: 'one', two: 'two' }
Unfortunately, Object.assign
doesn't copy accessors. (Accessor is a term for Getter and Setter functions). Object.assign
reads the value of a Getter function and copies that value instead.
let count = 0
const one = {}
const two = {
get count () { return count },
set count (value) { count = value }
}
const three = Object.assign({}, one, two)
console.log('two:', two)
console.log('three:', three)
Try logging two
and three
in a Node environment. Accessors will be logged clearly. You'll immediately see that three.count
is NOT an accessor.
Copying Accessors
MDN's article about Object.assign
states this. If you want to copy accessors, you need to:
- Get the property's descriptor with
Object.getOwnPropertyDescriptor
- Create a property with
Object.defineProperty
Object.getOwnPropertyDescriptor
Object.getOwnPropertyDescriptor
tells you more information about a property. This information includes:
-
value
: Value of the property (if any) -
get
: Getter function (if any) -
set
: Setter function (if any) -
writable
: Whether the property can be edited -
configurable
: Whether the property can be edited and deleted -
enumerable
: Whether the property can be enumerated
We don't need to use advanced features like writable
, configurable
, and enumerable
normally. So there's no need to use getPropertyDescriptor
much in practice.
Syntax:
const descriptor = Object.getOwnPropertyDescriptor(object, 'property')
If you grab a normal property, you'll see a value
key.
const object = {
normalProperty: 'hello world',
}
const descriptor = Object.getOwnPropertyDescriptor(object, 'normalProperty')
console.log(descriptor)
// Output
// {
// value: 'hello world',
// writable: true,
// enumerable: true,
// configurable: true
// }
If you log the descriptor of an accessor, you'll see get
and set
keys.
let count = 0
const two = {
get count () { return count }
set count (value) { count = value }
}
const descriptor = Object.getOwnPropertyDescriptor(two, 'count')
console.log(descriptor)
Object.getDefineProperty
Object.defineProperty
lets you create a property. It lets you configure the same 6 values you find in Object.getOwnPropertyDescriptor
.
-
value
: Value of the property (if any) -
get
: Getter function (if any) -
set
: Setter function (if any) -
writable
: Whether the property can be edited -
configurable
: Whether the property can be edited and deleted -
enumerable
: Whether the property can be enumerated
Object.defineProperty
can only be used after the object is created.
Syntax:
Object.defineProperty(object, property, desciptor)
Example:
const object = {}
Object.defineProperty(object, 'normalProperty', { value: 'Hello world'})
console.log(object) // { normalProperty: 'Hello world' }
There's no need to use Object.defineProperty
for normal properties, unless you want to change the writable
, configurable
, or enumerable
settings.
If you simply need to create a property with a value, you can use notation we're used to:
// Same result as above
const object = {}
object.normalProperty = 'Hello world'
Object.defineProperty
is useful when you need to create accessors AFTER an object is created. This is because accessor shorthands can only be used when you create the object. They cannot be used afterwards.
// Creating a `count` getter function with Accessor shorthands
const object = {
get count () {}
}
If you want to add an accessor to a defined object , you need Object.defineProperty
// Same result as above
const object = {}
Object.defineProperty(object, 'count', {
get function () {
return count
}
}
Copying accessors
If we want to copy an accessor from one object to another, we can:
- Get the descriptor with
Object.getOwnPropertyDescriptor
- Create the property with
Object.defineProperty
Here's an example:
let count
const original = {
get count () { return count },
set count (value) { count = value }
}
const copy = {}
const descriptor = Object.getOwnPropertyDescriptor(original, 'count')
Object.defineProperty(copy, 'count', descriptor)
console.log('copy:', copy)
Copying all properties of an object
It's easy to copy all properties of an object once you know how to copy one. You can loop through all enumerable properties and run the same two lines of code.
const original = {
normalProperty: 'hello world',
get count () { return count },
set count (value) { count = value }
}
const copy = {}
// Copies all properties from original to copy
const props = Object.keys(original)
for (const prop of props) {
const descriptor = Object.getOwnPropertyDescriptor(original, prop)
Object.defineProperty(copy, prop, descriptor)
}
console.log('copy:', copy)
Merging different object sources
If we want to copy properties from multiple sources, we need to create a function that takes in all possible sources. Let's call this function mix
.
function mix (...sources) {
// ...
}
We will then loop through each source and copy properties into a new object.
function mix (...sources) {
const result = {}
for (const source of sources) {
const props = Object.keys(source)
for (const prop of props) {
const descriptor = Object.getOwnPropertyDescriptor(source, prop)
Object.defineProperty(result, prop, descriptor)
}
}
return result
}
mix
can be used like Object.assign
now.
let count = 0
const one = { one: 'one' }
const two = { two: 'two' }
const three = {
get count () { return count },
set count (value) { count = value }
}
const mixed = mix({}, one, two, three)
console.log('mixed:', mixed)
The great part is mix
doesn't mutate objects. You don't have o pass in an empty object.
// Produces the same result as above
const mixed = mix(one, two, three)
Shallow Merge vs Deep Merge
Object.assign
doesn't work well with nested objects. If you copy a nested object, that nested object can still be mutated.
const one = {}
const two = { nested: { value: 'two' } }
const three = Object.assign({}, one, two)
// Nested values are mutated when changed
three.nested.value = 'three'
console.log(two.nested.value) // 'three'
Our mix
function works the same way as Object.assign
. That's not ideal.
// Same result as above
const one = {}
const two = { nested: { value: 'two' } }
const three = mix(one, two)
// Nested values are mutated when changed
three.nested.value = 'three'
console.log(two.nested.value) // 'three'
Both Object.assign
and mix
perform what we call a shallow merge. A shallow merge is when you copy and paste first-layer properties completely into a new object. Properties belonging to a nested object still get pointed to the same reference.
Note: if you're confused "references", read this analogy about Identity cards. It'll clear things up.
We don't want nested objects to point to the same references because it can mutate without us knowing. This kind of mutation is a source of hard-to-find bugs. We want to perform a deep merge instead (where we create new versions of nested objects in the new object).
Ways to Deep Merge
Many people have created ways to perform deep merging already. Examples include:
These libraries work like Object.assign
.
- You pass in a comma-separated list of objects to merge.
- The library will merge the object and it will return a new object.
There are slight differences though.
assignment
works exactly like Object.assign
. The first object you passed in will get mutated. So you need to pass in an empty object.
const one = {}
const two = { nested: { value: 'two' } }
const three = assignment({}, one, two)
merge-options
and deepmerge
creates an empty object for you automatically. So you don't have to pass in an empty object as the first argument.
const mergeOoptions = require('merge-options')
const one = {}
const two = { nested: { value: 'two' } }
const three = mergeOptions(one, two)
While testing this, I discovered a bug with deepmerge
. If you pass an empty object as the first argument, deepmerge
will return an empty object. Not sure why.
const deepmerge = require('deep-merge')
const one = {}
const two = { nested: { value: 'two' } }
const three = deepmerge({}, one, two)
console.log(three) // {} ....... 🤷♂️
Unfortunately, none of these methods support the copying of accessors.
const mergeOoptions = require('merge-options')
let count = 0
const one = {}
const two = {
get count () { return count } ,
set count (value) { count = value }
}
const three = mergeOptions(one, two)
console.log('two:' two)
console.log('three:', three)
Deep Merging that includes Accessors
I couldn't find a library that lets you perform a deep merge while copying accessors. I don't know why people haven't created it yet 😢.
So I went ahead and created one. It's called mix
. Here's the code for mix. (I'll explain how I created mix
in the next article, which should be fun!).
Let me tell you what mix
is capable of.
Two features of mix
First, mix
copies accessors.
let count = 0
const one = {}
const two = {
get count () { return count },
set count (value) { count = value }
}
const three = mix(one, two)
console.log('two:', two)
console.log('three:', three)
Second, mix
copies nested objects and arrays so you don't have to worry about mutation.
const one = {}
const two = { nested: { value: 'two' } }
const three = mix(one, two)
// Nested values do not get mutated
three.nested.value = 'three'
console.log(two.nested.value) // 'two'
That's it!
I'd appreciate it if you take mix
out for a spin and let me know if you have any feedback!
Thanks for reading. This article was originally posted on my blog. Sign up for my newsletter if you want more articles to help you become a better frontend developer.