Creating a deep-assign library

zellwk

Zell Liew πŸ€—

Posted on August 20, 2020

Creating a deep-assign library

I created a library to merge objects last week. It's called mix. mix lets you perform a deep merge between two objects.

The difference between mix and other deep merging libraries is: mix lets you copy accessors while others don't.

You can find out more about mix in last week's article.

I thought it'll be fun to share the process (and pains) while building the library. So here it is.

It started with solving a problem I had

I started playing with accessor functions recently. One day, I noticed accessors don't work when they're copied via Object.assign. Since I wanted to copy accessors, Object.assign didn't work for me anymore.

I need another method.

I did some research and discovered I can create an Object.assign clone that supports the copying of accessors quite easily.

// First version, shallow merge.
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

I explained the creation process for this simple mix function in my previous article, so I won't say the same thing again today. Go read that one if you're interested to find out more.

This simple mix function was okay. But it wasn't enough.

I wanted a way to make merge objects without worrying about mutation since mutation can be a source of hard-to-find bugs. This meant I needed a way to recursively clone objects.

Researching other libraries

First, I searched online to see if anyone created a library I needed. I found several options that copied objects, but none of them allowed copying of accessors.

So I had to make something.

In the process, I discovered I can use a combination of Lodash's assign and deepClone functions to achieve what I want easily.

Update: Mitch Neverhood shared that Lodash has a merge function that was deep. If we wanted an immutable merge, we could do this:

import { cloneDeep, merge } from 'lodash';
export const immutableMerge = (a, b) => merge(cloneDeep(a), b);
Enter fullscreen mode Exit fullscreen mode

But Lodash was too heavy for me. I don't want to include such a big library in my projects. I wanted something light and without dependencies.

So I made a library.

A journey into deep cloning objects

When I started, I thought it's easy to create deep clones of an object. All I had to do was

  1. Loop through properties of an object
  2. If the property is an object, create a new object

Cloning object properties (even for accessors) are simple enough. I can replace the property's descriptor value with a new object via Object spread.

const object = { /* ... */ }
const copy = {}
const props = Object.keys(object)

for (const prop of props) {
  const descriptor = Object.getOwnPropertyDescriptor(object, prop)
  const value = descriptor.value
  if (value) descriptor.value = { ...value }
  Object.defineProperty(copy, prop, descriptor)
}
Enter fullscreen mode Exit fullscreen mode

This wasn't enough because Object spread creates a shallow clone.

I needed recursion. So I created a function to clone objects. I call it cloneDescriptorValue (because I was, in fact, cloning the descriptor's value).

// Creates a deep clone for each value
function cloneDescriptorValue (value) {
  if (typeof value === 'object) {
    const props = Object.keys(value)
    for (const prop of props) {
      const descriptor = Object.getOwnPropertyDescriptor(value, prop)
      if (descriptor.value) descriptor.value = cloneDescriptorValue(descriptor.value)
      Object.defineProperty(obj, prop, descriptor)
    }
    return obj
  }

  // For values that don't need cloning, like primitives for example
  return value
}
Enter fullscreen mode Exit fullscreen mode

I used cloneDescriptorValue like this:

const object = { /* ... */ }
const copy = {}
const props = Object.keys(object)

for (const prop of props) {
  const descriptor = Object.getOwnPropertyDescriptor(object, prop)
  const value = descriptor.value
  if (value) descriptor.value = cloneDescriptorValue(value)
  Object.defineProperty(copy, prop, descriptor)
}
Enter fullscreen mode Exit fullscreen mode

This clones objects (including accessors) recursively.

But we're not done.

Cloning arrays

Although Arrays are objects, they're special. I cannot treat them like normal objects. So I had to devise a new way.

First, I needed to differentiate between Arrays and Objects. JavaScript has an isArray method that does this.

// Creates a deep clone for each value
function cloneDescriptorValue (value) {
  if (Array.isArray(value)) {
    // Handle arrays
  }

  if (typeof value === 'object) {
    // Handle objects
  }

  // For values that don't need cloning, like primitives for example
  return value
}
Enter fullscreen mode Exit fullscreen mode

Arrays can contain any kind of value. If the array contained another array, I must clone the nested array. I did this by running every value through cloneDescriptorValue again.

This takes care of recursion.

// Creates a deep clone for each value
function cloneDescriptorValue (value) {
  if (Array.isArray(value)) {
    const array = []
    for (let v of value) {
      v = cloneDescriptorValue(v)
      array.push(v)
    }
    return array
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

I thought I was done. But I wasn't 😒.

Cloning functions...?

The next day, I wondered if it's possible to clone functions. We don't want functions to mutate either, don't we?

I wasn't sure whether I should do this. I wasn't sure whether it was possible to clone functions too.

A google search brought me to this deep-cloning article where I was reminded about other Object types like Date, Map, Set, and RegExp. (More work to do). It also talked about Circular references (which I did not handle in my library).

I forgot all about cloning functions at this point. I went into the rabbit hole and tried to find ways to deep clone objects without writing each type of object individually. (I'm lazy).

While searching, I discovered a thing known as the Structured Clone Algorithm. This sounds good. It's exactly what I wanted! But even though the algorithm exists, there's no way to actually use it. I couldn't find its source anywhere.

Then, I chanced upon Das Surma's journey into deep-copying which talks about the Structured Clone Algorithm and how to use it. Surma explained we can use this Structured Clone Algorithm via three methods:

  1. MessageChannel API
  2. History API
  3. Notification API

All three API exist in browsers only. I wanted my utility to work both in Browsers and in Node. I couldn't use any of these methods. I had to look for something else.

The next day, I thought of Lodash. So I did a quick search. Lodash didn't have a deep merge method. But I could clobber something together with _.assign and _.cloneDeep if I wanted.

In its documentations, Lodash explained _.cloneDeep (which recursively uses _.clone) was loosely based on the Structured Clone Algorithm. I was intrigued and dove into the source code.

Long story short, I wasn't able to use Lodash's source code directly since it was such a complicated library. But I managed to find a piece of gem that looked like this:

var argsTag = '[object Arguments]',
    arrayTag = '[object Array]',
    boolTag = '[object Boolean]',
    dateTag = '[object Date]',
    errorTag = '[object Error]',
    funcTag = '[object Function]',
    genTag = '[object GeneratorFunction]',
    mapTag = '[object Map]',
    numberTag = '[object Number]',
    objectTag = '[object Object]',
    regexpTag = '[object RegExp]',
    setTag = '[object Set]',
    stringTag = '[object String]',
    symbolTag = '[object Symbol]',
    weakMapTag = '[object WeakMap]';

var arrayBufferTag = '[object ArrayBuffer]',
    dataViewTag = '[object DataView]',
    float32Tag = '[object Float32Array]',
    float64Tag = '[object Float64Array]',
    int8Tag = '[object Int8Array]',
    int16Tag = '[object Int16Array]',
    int32Tag = '[object Int32Array]',
    uint8Tag = '[object Uint8Array]',
    uint8ClampedTag = '[object Uint8ClampedArray]',
    uint16Tag = '[object Uint16Array]',
    uint32Tag = '[object Uint32Array]';

/** Used to identify `toStringTag` values supported by `_.clone`. */
var cloneableTags = {};
cloneableTags[argsTag] = cloneableTags[arrayTag] =
cloneableTags[arrayBufferTag] = cloneableTags[dataViewTag] =
cloneableTags[boolTag] = cloneableTags[dateTag] =
cloneableTags[float32Tag] = cloneableTags[float64Tag] =
cloneableTags[int8Tag] = cloneableTags[int16Tag] =
cloneableTags[int32Tag] = cloneableTags[mapTag] =
cloneableTags[numberTag] = cloneableTags[objectTag] =
cloneableTags[regexpTag] = cloneableTags[setTag] =
cloneableTags[stringTag] = cloneableTags[symbolTag] =
cloneableTags[uint8Tag] = cloneableTags[uint8ClampedTag] =
cloneableTags[uint16Tag] = cloneableTags[uint32Tag] = true;
cloneableTags[errorTag] = cloneableTags[funcTag] =
cloneableTags[weakMapTag] = false;
Enter fullscreen mode Exit fullscreen mode

This piece tells me two things:

  1. How to determine different types of objects like (RegExp, Map, Set, etc).
  2. What objects are clone-able, and what objects aren't.

I can see that functions cannot be cloned, which makes sense, so I stopped trying to clone functions.

// Part that tells me functions cannot be cloned
cloneableTags[errorTag] = cloneableTags[funcTag] =
cloneableTags[weakMapTag] = false;
Enter fullscreen mode Exit fullscreen mode

Cloning other types of objects

The problem remains: I still need to recursively create clones for other types of objects. I started by refactoring my code to detect other object types.

function cloneDescriptorValue (value) {
  if (objectType(value) === '[object Array]') {
    // Handle Arrays
  }

  if (objectType(value) === '[object Object]') {
    // Handle pure objects
  }

  // Other values that don't require cloning
  return
}

function objectType (value) {
  return Object.prototype.toString.call(value)
}
Enter fullscreen mode Exit fullscreen mode

Then I started working on the simplest object type: Dates.

Cloning Dates

Dates are simple. I can create a new Date value that contains the same timestamp as the original Date.

function cloneDescriptorValue (value) {
  // Handle Arrays and Objects

  if (objectType(value) === '[object Date]') {
    return new Date(value.getTime())
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

I tackled Maps next.

Deep Cloning Map

Map is like Object with a few differences.

One of them is: You can use objects as keys. If you used an object as a key, you won't be able to retrieve the key's values if I created a new object.

So I opt to create clones only for map values.

function cloneDescriptorValue (value) {
  // ...
  if (objectType(value) === '[object Map]') {
    const map = new Map()
    for (const entry of value) {
      map.set(entry[0], cloneDescriptorValue(entry[1]))
    }
    return map
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

I didn't clone WeakMaps because we cannot iterate through WeakMaps. It's was technically impossible to create a clone.

Deep Cloning Set

Sets are like arrays, but they contain unique values only. I decided to create a new reference for values in Sets because Lodash does it as well.

function cloneDescriptorValue (value) {
  // ...
  if (objectType(value) === '[object Set]') {
    const set = new Set()
    for (const entry of value.entries()) {
      set.add(cloneDescriptorValue(entry[0]))
    }
    return set
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

More types...

I decided to stop working on other types because I don't use them at all. I didn't want to write extra code that I won't use (especially if no one else uses the library)

Tests

Of course, with any library creation, it's important to write tests to ensure the library functions correctly. I wrote a couple of them while creating this project. 😎

Update: Preventing Prototype Pollution

Kyle Wilson asked how I was preventing Prototype Pollution. I had complete no idea what he was talked about, so I did a search.

Turns out, Prototype Pollution was a serious issue that used to be present in jQuery and Lodash. It may still be present in many libraries today! You can read more about it here.

Without going into too much details, I just want to let you know I fixed this issue.

Final mix function

That's it! Here's the final mix function I created.

I hope this article gives you an experience of the roller coaster ride when I experienced when creating the library. It's not easy to create a library. I deeply appreciate people out there who have done the work and shared it with others.


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.

πŸ’– πŸ’ͺ πŸ™… 🚩
zellwk
Zell Liew πŸ€—

Posted on August 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related