Create a Immutability Helper in JavaScript

Implement a simple immutability helper in JavaScript that allows a certain set of actions to update the frozen input object. The input object can only be updated through this function and the returned value is also frozen.

Note โ€“ For simplicity, only one operation is allowed at a time.

Actions

_push_ : Array โ€“ Pushes of the destination array in the input array.

const inputArr = [1, 2, 3, 4]
const outputArr = update(
  inputArr,  {_push_: [5, 6, 7]}
);

console.log(outputArr);
// [1,2,3,4,5,6,7]

_replace_ : Object | Array โ€“ Replaces the destination value in the input object.

--- Object ---
const state = {
  a: {
    b: {
      c: 1
    }
  },
  d: 2
};

const newState = update(
  state, 
  {a: {b: { c: {_replace_: 3}}}}
);

console.log(newState);
/*
{
  "a": {
    "b": {
      "c": 3
    }
  },
  "d": 2
}
*/

--- Array ---
const inputArr = [1, 2, 3, 4]
const outputArr = update(
  inputArr, 
  {1: {_replace_: 10}}
);

console.log(outputArr);

// [1,10,3,4]

_merge_ : Object โ€“ Merges the destination value in the input object.

const state = {
  a: {
    b: {
      c: 1
    }
  },
  d: 2
};

const newState = update(
  state, 
  {a: {b: { _merge_ : {e: 5 }}}}
);

console.log(newState);

/*
{
  "a": {
    "b": {
      "c": 1,
      "e": 5
    }
  },
  "d": 2
}
*/

_transform_ : Object | Array โ€“ Transforms the destination value by passing it through this function.

const inputArr = {a: { b: 2}};
const outputArr = update(inputArr, {a: { b: {_transform_: (item) => item * 2}}});

console.log(outputArr);
/*
{
  "a": {
    "b": 4
  }
}
*/

The implementation of this is straightforward. The helper(data, action) accepts two arguments, the action is always an Object.

Thus we can recursively deep traverse the object and in each call check if the current key is any of the actions then perform the action accordingly.

Otherwise depending if the input an array or object recursively call the same function with the current value to update.

Wrap this helper function inside another parent function. As the input object will frozen, we cannot directly update it, thus we will create a clone of it. Pass the clone for update through the helper function and in the end freeze the output before returning it back.

// function to deepfreeze
function deepFreeze(object) {
  // Retrieve the property names defined on object
  var propNames = Object.getOwnPropertyNames(object);

  // Freeze properties before freezing self
  for (let name of propNames) {
    let value = object[name];

    object[name] = value && typeof value === "object" ? 
      deepFreeze(value) : value;
  }

  return Object.freeze(object);
};

// function to perform the action
function update(inputObj, action){
  const clone = JSON.parse(JSON.stringify(inputObj));
  
  function helper(target, action) {
    // iterate the entries of the action
    for (const [key, value] of Object.entries(action)) {

      // if the key is of action type
      // perform the action
      switch (key) {
        // add a new value
        case '_push_':
          return [...target, ...value];

        // replace the entry
        case '_replace_':
          return value;

        // merge the values
        case '_merge_':
          if (!(target instanceof Object)) {
            throw Error("bad merge");
          }

          return {...target, ...value};

        // add the transformed value
        case '_transform_':
          return value(target);

        // for normal values
        default:

          // if it is an array
          if (target instanceof Array) {
            // create a copy
            const res = [...target];

            // update the value
            res[key] = update(target[key], value);

            // return after update
            return res;
          } 
          // if it is an object
          else {
            // recursively call the same function
            // and update the value
            return {
              ...target,
              [key]: update(target[key], value)
            }
         }
      }
    };
  };
  
  // perform the operation
  const output = helper(clone, action);
  
  // freeze the output
  deepFreeze(output);
  
  //return it
  return output;
};

Test Case 1: _push_

const inputArr = [1, 2, 3, 4]
const outputArr = update(
  inputArr,  {_push_: [5, 6, 7]}
);

console.log(outputArr);
// [1,2,3,4,5,6,7]

Test Case 2: _replace_

const state = {
  a: {
    b: {
      c: 1
    }
  },
  d: 2
};

// freeze the object
deepFreeze(state);

const newState = update(
  state, 
  {a: {b: { c: {_replace_: 3}}}}
);

// does not updates 
// as output is frozen
newState.a.b.c = 10;

console.log(newState);
/*
{
  "a": {
    "b": {
      "c": 3
    }
  },
  "d": 2
}
*/

Test Case 3: _merge_

const state = {
  a: {
    b: {
      c: 1
    }
  },
  d: 2
};

// freeze the object
deepFreeze(state);

const newState = update(
  state, 
  {a: {b: { _merge_ : {e: 5 }}}}
);

// does not updates 
// as output is frozen
newState.a.b.e = 10;

console.log(newState);

/*
{
  "a": {
    "b": {
      "c": 1,
      "e": 5
    }
  },
  "d": 2
}
*/

Test Case 4: _transform_

const state = {a: { b: 2}};

// freeze the object
deepFreeze(state);

const newState = update(state, {a: { b: {_transform_: (item) => item * 2}}});

// does not updates 
// as output is frozen
newState.a.b = 10;

console.log(newState);
/*
{
  "a": {
    "b": 4
  }
}
*/