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....
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
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 ?
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.
Shalu
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 !!
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.
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
Deepu
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.
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 from the comments 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);
}
Top comments (7)
Your logic has a bug.
let a = {}; a.b = {}; a.b.c = a; // circular dependency
a.x = 'x'; // to track if deepclone is working or not
let a_cl = deepCopy(a);
a_cl.x = 'xx';
console.log(a_cl.b.c.x)
Above line print 'x' which is wrong. It should print 'xx';
This value is coming from "a" instead it should be coming from "a_cl".
When you do "return obj;" it does not clone the cycle and istead uses the original cycle.
Thank you for pointing that out ๐. I am thinking of fixing it by using WeakMap to store corresponding newObj for obj and returning the newObj when same obj occurs. I think that should work.
It's not pretty, but it works:
?
Itโs a joke man ๐ . I know parse, stringify works. Itโs fast too. Actually, one of my friends got this asked in an interview and thus this article.
I would prefer this all day to that chunky angorithm, is there any reason this is worse?