Rethinking deepClone

As you know in JavaScript, objects are copied by reference. But sometimes you need to do deep cloning of an object. Many js libraries offer their implementation of the deepClone function for this case. But, unfortunately, most libraries do not take into account several important things:





To whom I can read, I put the full code under the spoiler
function deepClone(source) { return ({ 'object': cloneObject, 'function': cloneFunction }[typeof source] || clonePrimitive)(source)(); } function cloneObject(source) { return (Array.isArray(source) ? () => source.map(deepClone) : clonePrototype(source, cloneFields(source, simpleFunctor({}))) ); } function cloneFunction(source) { return cloneFields(source, simpleFunctor(function() { return source.apply(this, arguments); })); } function clonePrimitive(source) { return () => source; } function simpleFunctor(value) { return mapper => mapper ? simpleFunctor(mapper(value)) : value; } function makeCloneFieldReducer(source) { return (destinationFunctor, field) => { const descriptor = Object.getOwnPropertyDescriptor(source, field); return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? { ...descriptor, value: deepClone(descriptor.value) } : descriptor)); }; } function cloneFields(source, destinationFunctor) { return (Object.getOwnPropertyNames(source) .concat(Object.getOwnPropertySymbols(source)) .reduce(makeCloneFieldReducer(source), destinationFunctor) ); } function clonePrototype(source, destinationFunctor) { return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source))); }
      
      





My implementation is written in a functional style that provides me with reliability, stability and simplicity. But since, unfortunately, many still cannot reconstruct their thinking from the procedural and pseudo-OOP, I will explain each building brick of my implementation:



The deepClone function itself will take 1 argument source - the source from which we will clone, and will return its deep clone with all the above features:



 function deepClone(source) { return ({ 'object': cloneObject, 'function': cloneFunction }[typeof source] || clonePrimitive)(source)(); }
      
      





Everything is simple here, depending on the type of data in source, a function is selected that can clone it, and source itself is transferred to it.



You can also notice that the returned result is called as a function without parameters before being returned to the user. This is necessary, since I wrap the value into which I clone, in the simplest functor, in order to be able to mutate it without violating the purity of the auxiliary functions. Here is the implementation of this functor:



 function simpleFunctor(value) { return mapper => mapper ? simpleFunctor(mapper(value)) : value; }
      
      





He can do 2 things - map (if the mapper function is passed to him) and extract (if nothing is passed).



Now we will analyze the auxiliary functions cloneObject, cloneFunction and clonePrimitive. Each of them takes 1 argument of source of a specific type and returns its clone.



The implementation of cloneObject must take into account that arrays are also of type object, well, in other cases, they must clone the fields and prototype. Here is its implementation:



 function cloneObject(source) { return (Array.isArray(source) ? () => source.map(deepClone) : clonePrototype(source, cloneFields(source, simpleFunctor({}))) ); }
      
      





The array can be copied using the slice method, but since we have deep cloning and the array can contain not only primitive values, the map method is used with the deepClone described above as an argument.



For other objects, we create a new object and wrap it in our functor described above, clone the fields (along with descriptors) using the cloneFields helper function, and then clone the prototype using clonePrototype.



Helper functions I will describe below. In the meantime, consider the implementation of cloneFunction :



 function cloneFunction(source) { return cloneFields(source, simpleFunctor(function() { return source.apply(this, arguments); })); }
      
      





You simply cannot clone a function with all the logic. But you can wrap it in another function that calls the original with all arguments and context, and returns its result. Such a β€œclone” will certainly keep the original function in memory, but it will β€œweigh” a little and fully reproduce the original logic. We wrap the cloned function in a functor and using cloneFields we copy all the fields from the original function into it, since the function in JS is also an object, just called, and therefore can store fields in itself.



Potentially, a function may have a prototype different from Function.prototype, but I did not consider this extreme case. One of the charms of FP is that we can easily add a new wrapper over an existing function in order to implement the necessary functionality.



The last clonePrimitive building brick is for cloning primitive values. But since primitive values ​​are copied by value (or by reference, but are immutable in some implementations of JS engines), we can simply copy them. But since we are not expected to get a pure value, but a value wrapped in a functor that extract can call without arguments, we will wrap our value in a function:



 function clonePrimitive(source) { return () => source; }
      
      





Now we implement the auxiliary functions that were used above - clonePrototype and cloneFields



To clone a prototype, clonePrototype will simply extract the prototype from the source object and, by performing a map operation on the resulting functor, set it to the target object:



 function clonePrototype(source, destinationFunctor) { return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source))); }
      
      





Cloning fields is a bit more complicated, so I split the cloneFields function into two. The external function takes the concatenation of all named fields and all symbol fields, receiving absolutely all fields, and runs them through the reducer created by the auxiliary function:



 function cloneFields(source, destinationFunctor) { return (Object.getOwnPropertyNames(source) .concat(Object.getOwnPropertySymbols(source)) .reduce(makeCloneFieldReducer(source), destinationFunctor) ); }
      
      





makeCloneFieldReducer should create a reducer function for us that could be passed to the reduce method on an array of all the fields of the source object. As a battery, our functor that stores the target will be used. The reducer must extract the handle from the field of the source object and assign it to the field of the target object. But here it is important to consider that there are two types of descriptors - with value and with get / set. Obviously, value needs to be cloned, but with get / set there is no such need, such a descriptor can be returned as is:



 function makeCloneFieldReducer(source) { return (destinationFunctor, field) => { const descriptor = Object.getOwnPropertyDescriptor(source, field); return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? { ...descriptor, value: deepClone(descriptor.value) } : descriptor)); }; }
      
      





That's all. Such an implementation of deepClone solves all the problems posed at the beginning of the article. In addition, it is built on pure functions and one functor, which gives all the guarantees inherent in lambda calculus.



I also note that I did not implement excellent behavior for collections other than an array that would be worth cloning individually, such as Map or Set. Although in some cases this may be necessary.



All Articles