Making a basic deepCopy function in JS

Making a basic deepCopy function in JS

ยท

6 min read

Important - If planning to read this article, do it completely as there are some corrections done later.

Okay let's start ๐Ÿ‘‡

By default in JS, if we try to make a copy of an object, say obj, then either of the two helps us create Shallow copies :-

  • Object.assign({}, obj)
  • {...obj}

And the notorious yet popular JSON.parse(JSON.stringify(obj)) workaround can help us make a deep copy with the following limitations :-

  • If obj has methods, they won't be copied.
  • If obj has circular references, the above would simply throw an error.

This gives us an opportunity to make our own deepCopy function which can deal with the above limitations.

Let's dive into its epic creation via a conversation between Shalu and Deepu.

Shalu - I had a JS interview today and the interviewer asked me to build a custom deepCopy(obj) function to do guess what ? DEEP COPYING !!! But I only knew JSON.parse(JSON.stringify(obj)) workaround which clearly had limitations as pointed by the interviewer.

Deepu - Don't worry. We will try to implement our own basic deepCopy(obj) function which also takes care of those limitations. We will start simple, and gradually transform our function for the requirements. Take a look at this function :-

function deepCopy(obj) {
  const newObj = Array.isArray(obj) ? [] : {};
  for (const [key, value] of Object.entries(obj)) {
    newObj[key] = typeof value === 'object' ? deepCopy(value) : value;
  }
  return newObj;
}

Shalu - Well that's not gradual at all....

Deepu - Okay wait.... let me explain

const newObj = Array.isArray(obj) ? [] : {};

Deepu - We are initializing newObj to an empty Array or a POJO (Plain Old JavaScript Object) on basis of whether obj is an array or not.

for (const [key, value] of Object.entries(obj)) {
    newObj[key] = typeof value === 'object' ? deepCopy(value) : value;
  }
  return newObj;

Suppose obj was { name:'Saitama', age:'26' }, then Object.entries(obj) would return an array[ ['name','Saitama'],['age','26'] ].

So we are looping over de-structured key-value pair from this array and performing a conditional check.

The check is that if type of value is object, then assign the result of deepCopy(value) to newObj[key] else just assign value itself.

Shalu - Wait a minute !!! We are calling deepCopy(...) from within deepCopy(...). Isn't that recursion ?

Deepu

goddamn right

This use-case requires recursion. We don't know how many layers of nested objects our main obj might have. We only know that if the corresponding value for a key is not of type object, we can safely put the same key-value pair in our newObj. For the rest, we need to call deepCopy(value) again.

Shalu - But wait !!! What about Functions ? They are also JS Objects only right ?

Deepu obama you are right

They indeed are but their typeof is function. And this particular thing really works for us since we only need to assign these functions as value to a particular key and not worry about any nesting which is in the case of { } or [ ].

Shalu - So this is it right ?

Deepu - Well not quite yet. The above will fail tragically in the case of circular references.

no failure

Shalu

why tell me why

Deepu - Remember how we are recursing whenever the type of value is object ? Now consider that after 3 depths of recursion, we arrive at a key whose value is again the main obj i.e. there is a circular reference from a nested key to the main obj itself. This will result in an infinite loop of menace !!

infinity

Shalu - Oh damn!!! How would you handle this ?

Deepu - Well let's see what do we have at disposal. We need a mechanism to not recurse over already processed or seen object references.

Shalu - Cool so let's make a new obj, say , const seen = { } and use it as a dictionary.

Deepu - Well we need object references as key and { } only takes strings as keys.

Shalu Pikachu meme face

Deepu - We can make use of Map or Set here with the latter making more sense. And to take things up a notch, let's make use of WeakSet.

Shalu - Why WeakSet ?

Deepu - Because MDN says so !!

Functions that call themselves recursively need a way of guarding against circular data structures by tracking which objects have already been processed. WeakSets are ideal for this purpose.

Shalu - Alright I am excited for the final code

excited

Deepu

here we go

 function deepCopy(obj) {
  const seen = new WeakSet();

  function logic(obj) {
    const newObj = Array.isArray(obj) ? [] : {};
    if (!seen.has(obj)) {
      seen.add(obj);
      for (const [key, value] of Object.entries(obj)) {
        newObj[key] = typeof value === 'object' ? logic(value) : value;
      }
    } else {
      return obj;
    }
    return newObj;
  }

  return logic(obj);
}

Shalu - Damn that's quite big now.

Deepu - Well the flow is still simple. What we now did is initialize a WeakSet by the name seen inside deepCopy(...). And since we always needed access to seen while recursing, we extract all our recursion logic inside this logic(...) function. Also note we have applied the check using seen for the obj reference and if it doesn't exist, we add it to seen. Else, we don't bother performing the for loop logic for it and return the obj as it is. At the end of deepCopy(...) function we call logic(obj) (which will internally recurse as needed) as well as return its result.

Shalu wow

Thank you everyone who read it till here. This is an implementation that I have tried without referring anything online with the mindset that how will I do this if asked in an interview. Obviously the flow will be the same minus the incredible gifs ๐Ÿ˜‰ and you are free to evaluate me as an interviewer.

Correction

I got an important feedback that the above implementation doesn't clone the circular reference cycle successfully because I am returning the original obj when it's already present in seen. I should have been returning newObj corresponding to that obj here. For that, we would get rid of WeakSet altogether and use WeakMap instead like so :-

 function deepCopy(obj) {
  const seen = new WeakMap();

  function logic(obj) {
    const newObj = Array.isArray(obj) ? [] : {};
    if (!seen.has(obj)) {
      seen.set(obj, newObj);
      for (const [key, value] of Object.entries(obj)) {
        newObj[key] = typeof value === 'object' ? logic(value) : value;
      }
    } else {
      return seen.get(obj);
    }
    return newObj;
  }

  return logic(obj);
}

Possible enhancement - 1

 function deepCopy(obj) {
  const seen = new WeakMap();

  function logic(obj) {
    // Creating dynamic newObj using constructor
    const newObj = new obj.constructor();
    if (!seen.has(obj)) {
      seen.set(obj, newObj);
      for (const [key, value] of Object.entries(obj)) {
        newObj[key] = typeof value === 'object' ? logic(value) : value;
      }
    } else {
      return seen.get(obj);
    }
    return newObj;
  }

  return logic(obj);
}

BONUS - Fancy Reduce edit

function deepCopy(obj) {
  const seen = new WeakMap();

  function logic(obj) {
    if (!seen.has(obj)) {
      return Object.entries(obj).reduce((newObj, [key, value]) => {
        seen.set(obj, newObj);
        newObj[key] = typeof value === 'object' ? logic(value) : value;
        return newObj;
      }, new obj.constructor())
    } else {
      return seen.get(obj);
    }
  }

  return logic(obj);
}

Did you find this article valuable?

Support Lakshya Thakur by becoming a sponsor. Any amount is appreciated!

ย