Copying properties from one object to another (including Getters and Setters)

Zell Liew 🤗 - Aug 13 '20 - - Dev Community

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' }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Accessors are not copied into three.

Copying Accessors

MDN's article about Object.assign states this. If you want to copy accessors, you need to:

  1. Get the property's descriptor with Object.getOwnPropertyDescriptor
  2. Create a property with Object.defineProperty

Object.getOwnPropertyDescriptor

Object.getOwnPropertyDescriptor tells you more information about a property. This information includes:

  1. value: Value of the property (if any)
  2. get: Getter function (if any)
  3. set: Setter function (if any)
  4. writable: Whether the property can be edited
  5. configurable: Whether the property can be edited and deleted
  6. 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')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
// Output
// {
//   value: 'hello world',
//   writable: true,
//   enumerable: true,
//   configurable: true
// }
Enter fullscreen mode Exit fullscreen mode

Descriptor of a normal property.

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)
Enter fullscreen mode Exit fullscreen mode

Descriptor of an accessor.

Object.getDefineProperty

Object.defineProperty lets you create a property. It lets you configure the same 6 values you find in Object.getOwnPropertyDescriptor.

  1. value: Value of the property (if any)
  2. get: Getter function (if any)
  3. set: Setter function (if any)
  4. writable: Whether the property can be edited
  5. configurable: Whether the property can be edited and deleted
  6. enumerable: Whether the property can be enumerated

Object.defineProperty can only be used after the object is created.

Syntax:

Object.defineProperty(object, property, desciptor)
Enter fullscreen mode Exit fullscreen mode

Example:

const object = {}
Object.defineProperty(object, 'normalProperty', { value: 'Hello world'})

console.log(object) // { normalProperty: 'Hello world' }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 () {}
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Copying accessors

If we want to copy an accessor from one object to another, we can:

  1. Get the descriptor with Object.getOwnPropertyDescriptor
  2. 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)
Enter fullscreen mode Exit fullscreen mode

Copied the  raw `count` endraw  accessor.

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)
Enter fullscreen mode Exit fullscreen mode

Copied all properties, including accessors.

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) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Combined properties and accessors into a new object with mix

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)
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Assignment by Nicolás Bevacqua
  2. Merge-options by Michael Mayer
  3. Deepmerge by Josh Duff

These libraries work like Object.assign.

  1. You pass in a comma-separated list of objects to merge.
  2. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) // {} ....... 🤷‍♂️
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Merge options doesn't copy accesors into the new object.

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)
Enter fullscreen mode Exit fullscreen mode

Mix copies accessors.

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'
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .