1

I am looking for a generic solution as to how to determine when to overwrite or extend arrays/collections.

Say we have this object:

let obj1 = {

  rolo: 'bar',
  yolo: true,
  cholo: [1,2,3,4]

};

if I call

let obj2 = Object.assign({}, obj1, {
  cholo: [5]
});

I will get this:

   obj2 => { rolo: 'bar', yolo: true, cholo: [ 5 ] }

but what I am looking for is:

  obj2 => { rolo: 'bar', yolo: true, cholo: [ 1, 2, 3, 4, 5 ] }

What is the best pattern I can use to allow for extending arrays instead of overwriting? I am especially interested in doing this in the context of library code. Where the user can choose whether to extend the array or overwrite the array.

I know of no easy or simple pattern to use to do this.

For example, we could do this:

  override: {

      metadata:{
        cholo: {
           extend: true
        }
      },

      value: {
        cholo: [5]
      }
   }

adding some metadata to go with the new object. Then I would have some custom function that doesn't just call Object.assign but checks for metadata properties, and manually copies properties.

Something like this:

let copier = function(defaultObj, {value, metadata}){

   let allKeysDupes = Object.keys(value).concat(Object.keys(defaultObj));
   let allKeys = allKeysDupes.filter(i => allKeysDupes.indexOf(v === i));
   let ret = {}; 

   // manually go through all keys
   // check metadata to determine whether to overwrite or extend arrays, etc.

   allKeys.forEach(function(k){

     if(metadata[k]{
       // check the metadata and follow it's rules


     }
     else if(k in val){
       ret[k] = val[k];
     }
     else {
       ret[k] = defaultObj[k];
     }

   });

   return ret;
}
Alexander Mills
  • 90,741
  • 139
  • 482
  • 817
  • This sounds similar to something I've been trying to figure out. Have you looked at JSON Diff/Patch? https://github.com/benjamine/jsondiffpatch – Ken Jun 25 '17 at 04:02

3 Answers3

2

A generic solution using ES6 Proxies

The following is a generic solution leveraging ES6 Proxies (introduced in the ECMAScript 2015 Language Specification (ES6)) to perform a customizable deep merge and concatenation of any normal objects it is passed. This function allows for easy customization of the merging process for each type of input value encountered and should be able to handle any type of input value, so long as the objects it is passed would normally work with Object.assign().


Explanation

Getting types accurately

It is important for handling purposes to easily and accurately identify the type of the input values. To do this the function leverages Object.prototype.toString which (when called with against an arbitrary value) will return a string along the lines of [object String] or [object Object], etc. From there we can strip out the unnecessary bits to end up with a string like String or Object, etc.

const gettype = e => Object.prototype.toString.call(e).replace(/.*\b(\w+)./, '$1');

Handling the handlers

We need to streamline handling of different data types. You could use messy, hard-coded switch statements, but I prefer something a bit more fluid. I like using an object of functions. For each type we want to handle, we simply define a property name that directly matches the output of the gettype function defined above. Then when needed, we can look up that function based on the corresponding type value.

In this example, I create a default set of handlers, which is then overwritten by the supplied handlers provided in the first argument of the function.

Object.assign(h, {
    "Array": (a, b) => [...a, ...b],
    "Object": (a, b) => deepmerge(h, a, b),
    "Set": (a, b) => new Set([...a, ...b]),
    "Map": (a, b) => {
        const o = new Map([...a]);
        for(let [k,v] of [...b]) {
            const vt = gettype(v);
            o.set(k, a.has(k) && vt in h ? h[vt](a.get(k), v) : v);
        }
        return o;
    }
}, h);

Intercepting the set operation using proxies

Now we will get into the proxy part. First define an "out" object. This object is what will eventually be returned by this function, and will serve as the target for the proxy.

const o = {};

Next create a proxy for the "out" object with a set trap. The set trap passes the original and new values on to the appropriate set handlers when a set operation happens. If there is no appropriate set handler, it overwrites the old value with the new value. The determined value is applied to the "out" object.

const p = new Proxy(o, {
    set: (o, k, b) => {
        const a = o[k];
        const at = gettype(a);
        return (o[k] = (at in h && k in o ? h[at](a, b) : b), true);
    }
});

Next use Object.assign to merge the objects into the proxy, which triggers the set trap defined above for each set operation.

Object.assign(p, ...args);

Finally return the "out" object.

return o;

Full definition

const deepmerge = (h, ...args) => {
    const gettype = e => Object.prototype.toString.call(e).replace(/.*\b(\w+)./, '$1');

    const o = {};

    Object.assign(h, {
        "Array": (a, b) => [...a, ...b],
        "Object": (a, b) => deepmerge(h, a, b),
        "Set": (a, b) => new Set([...a, ...b]),
        "Map": (a, b) => {
            const o = new Map([...a]);
            for(let [k,v] of [...b]) {
                const vt = gettype(v);
                o.set(k, a.has(k) && vt in h ? h[vt](a.get(k), v) : v);
            }
            return o;
        }
    }, h);

    const p = new Proxy(o, {
        set: (o, k, b) => {
            const a = o[k];
            const at = gettype(a);
            return (o[k] = (at in h && k in o ? h[at](a, b) : b), true);
        }
    });

    Object.assign(p, ...args);

    return o;
};

Usage

deepmerge(handlers, ...sources)
  • Handlers

    The handler object. The handler object can be used to override the default handler for a given type, or provide handlers for types that are not handled by default.

    Example:

    {
            "String": (a, b) => a + b,
            "Number": (a, b) => a + b,
    }
    • The name of each property should be the name of the type being handled, with the first letter capitalized.
    • The value of each property should be a function which accepts two arguments and returns the result of merging those arguments.
  • Sources

    The objects being merged.
    • Each object being merged must be a plain object (no maps, sets, or arrays, etc).
    • Each object may contain values of any type.

Examples

The following snippet shows two examples:

  • The first uses just the default handlers
  • The second uses a few custom handlers to demonstrate customization of the merging process. Note the difference in the two resulting objects.

const objects = [
    {
        string: "hello", 
        number: 1, 
        boolean: true,
        array: [1,2,3], 
        object: { a: 1, b: 2 }, 
        map: new Map([[1,2],[2,3],[4,2],[undefined, undefined],[null, null]]), 
        set: new Set([1,2,3]),
        null: null,
        undefined: undefined
    },
    { 
        string: " world", 
        number: 2, 
        boolean: false, 
        array: [4,5,6], 
        object: { a: 2, b: 1 }, 
        map: new Map([[1,1],[2,0],[3,1],[undefined, null],[null, undefined]]), 
        set: new Set([4,5,6]),
        null: undefined,
        undefined: null
    }
];

console.log(deepmerge({}, ...objects));
console.log(deepmerge({
    "String": (a, b) => a + b,
    "Number": (a, b) => a + b,
}, ...objects));
<script src="https://cdn.rawgit.com/Tiny-Giant/43cc03adf3cdc84ff935655cbebbe585/raw/754070ca8858efeff5a2c3b8bad6475842565798/deepmerge.js"></script><link rel="stylesheet" href="https://cdn.rawgit.com/Tiny-Giant/f2a53f469863baadf1b4ad48a4b4ea39/raw/b0ede74f374199abe9324334d1c0ef088a850415/deepmerge.css" type="text/css">
1

I have taken this and modified for your requirement by introducing one new field concat to specify if you want to concat or not.

function isObject(item) {
  return (item && typeof item === 'object' && !Array.isArray(item));
}

function mergeDeep(concat = false, target, ...sources) {

  if (!sources.length) return target;
  const source = sources.shift();
  
  if (isObject(target) && isObject(source)) {
    for (const key in source) {
      if (isObject(source[key])) {
        if (!target[key]) Object.assign(target, { [key]: {} });
        mergeDeep(target[key], source[key]);
      } else {
        if(concat && Array.isArray(source[key]) && target[key]){
            target[key] = source[key].concat(target[key]);
        } else {
          Object.assign(target, { [key]: source[key] });
        }
      }
    }
  }
  return mergeDeep(concat, target, ...sources);
}

let obj1 = {
  rolo: 'bar',
  yolo: true,
  cholo: [1,2,3,4]
};

let arrObj = {
  cholo: [5]
};

console.log(mergeDeep(true, arrObj,{cholo:[2,3,4]}, obj1));

You can create a new object and then concat the array of the new object with old object

let obj1 = {
  rolo: 'bar',
  yolo: true,
  cholo: [1,2,3,4]
};

let arrObj = {
  cholo: [5]
};

let obj2 = Object.assign({}, obj1, arrObj);

obj2['cholo'] = obj1['cholo'].concat(obj2['cholo']);
console.log(obj2);
Hassan Imam
  • 21,956
  • 5
  • 41
  • 51
0
let obj2 = Object.assign({}, obj1, {
  cholo: obj1.cholo.concat([5])
});

Following your suggestion:

let fn = function({value, metadata}) : Object{

   let defaults = obj1;
   let ret = {}; 

   // manually go through value
   // check metadata to determine whether to overwrite or extend
   for (var key in value) {
     if (metadata[key] != null) {
       if (isArray(value[key]) && isArray(metadata[key]) {
         ret[key] = value[key].concat(metadata[key])
       } else if (typeof value[key] === 'object' && typeof metadata[key] === 'object') {
         ret[key] = Object.assign(value[key], metadata[key]);
       } else {
         ret[key] = value[key]
       }
     }
   }

   return ret;
}
Pat Needham
  • 5,698
  • 7
  • 43
  • 63