Creating a setter function in JavaScript Objects

As part of one of my projects I have had a central object called "layout" with it being to do with manipulating SVGs, and as part of that I decided it would be easier to write said layout object such that it has a getter and setter function, so that:

layout.item.points.X = 20;

…becomes:

layout.set('item.points.X',20);

…much more verbose and much more functional, I’m sure you’ll agree!

So first, the getter function, well, this couldn’t be easier really:

const layout = {
  get: function() {
    return this;
  },
  set: // To be confirmed
  /* All other properties */
}

But now, what about the setter? Well, that’s where it gets a bit more complicated. First, we need to take the propChain and newState from the user:

const layout = {
  set: function(propChain,newState) {}
}

I have written it so that it follows the usual object notation, as per the example above, so if you want to set item’s pointX value to 20 you give it layout.set('item.points.X',20).

First of all, let’s prevent the user from being able to change either the setter or getter:

const layout = {
  set: function(propChain,newState) {
    if (['get','set'].includes(propChain)) {
      console.warn(`🛑 What are you doing? Should you really be changing either of these functions? `);
      return;
    }
  }
}

And get our function to exit if the propChain is empty:

const layout = {
  set: function(propChain,newState) {
    if (['get','set'].includes(propChain)) {
      console.warn(`🛑 What are you doing? Should you really be changing either of these functions? `);
      return;
    }
    if (!propChain) return;
  }
}

Now for the fun bit! We want to split our propChain into sections and:

  • Check for the first property on our original object, and create it if it isn’t there
  • If it is there (which it now is) check if we’re at the end of the provided chain
  • If we are, set the property to equal the given newState
  • If not, go one level further into the object (having just created the new property) and repeat from step 1
const layout = {
  set: function(propChain,newState) {
    if (['get','set'].includes(propChain)) {
      console.warn(`🛑 What are you doing? Should you really be changing either of these functions? `);
      return;
    }
    if (!propChain) return;
    propChain.split('.').reduce((original/*Object*/,prop,level/*how deep we are into the chain*/) => {},this);
  }
}

So, um, what?

Let’s go through it bit-by-bit.

We use the split function to split propChain from a string into an array, using . as the breaking point (just how you’d access an object property in JavaScript anyway) on which we can now use the reduce function.

The reduce function is immensely powerful and I’m often guilty of discarding it in favour of map because I’m more comfortable there.

The reduce function takes up to 4 parameters (read more on MDN) but we’re only interested in the first 3: the accumulated value, the current value and the current index, which we’re calling original, prop and level.

const layout = {
  set: function(propChain,newState) {
    if (['get','set'].includes(propChain)) {
      console.warn(`🛑 What are you doing? Should you really be changing either of these functions? `);
      return;
    }
    if (!propChain) return;
    propChain.split('.').reduce((original,prop,level) => {
      // Firstly, check if our original object has the property, and add it if not.
      if (!(prop in original)) {
        original[prop] = {}; // Why a blank object? In case we go deeper into the chain and need to add properties to this, which you can't on undefined, 0 or an empty string
      }
    },this);
  }
}

Could I not have used original.hasOwnProperty(prop)? In JavaScript yes but in TypeScript the linter shouts at you: Do not access Object.prototype method 'hasOwnProperty' from target object no-prototype-builtins.

const layout = {
  set: function(propChain,newState) {
    if (['get','set'].includes(propChain)) {
      console.warn(`🛑 What are you doing? Should you really be changing either of these functions? `);
      return;
    }
    if (!propChain) return;
    propChain.split('.').reduce((original,prop,level) => {
      if (!(prop in original)) {
        original[prop] = {};
      }
      // Now check if we're at the end of our given chain and if we are, set the property to the given newState
      if (level === propChain.split('.').length - 1 /*Remember, indexing starts at 0*/) {
        original[prop] = newState;
      }
      // Now return our object, and that's it!
      return original[prop];
    },this);
  }
}

Finally we arrive at:

const layout = {
  get: function() {
    return this;
  },
  set: function(propChain,newState) {
    if (['get','set'].includes(propChain)) {
      console.warn(`🛑 What are you doing? Should you really be changing either of these functions? `);
      return;
    }
    if (!propChain) return;
    propChain.split('.').reduce((original,prop,level) => {
      if (!(prop in original)) {
        original[prop] = {};
      }
      if (level === propChain.split('.').length - 1) {
        original[prop] = newState;
      }
      return original[prop];
    },this);
  }
}

Or, in TypeScript:

interface LayoutObject extends Record<string, unknown> {
  get: () => LayoutObject;
  set: (
    propChain: string,
    newState: Record<string, unknown> | string | number
  ) => void;
  // All the rest of our properties
}

// TypeScript uses interfaces to define, well, almost everything!

const layout: LayoutObject = {
  get: function (): LayoutObject {
    return this;
  },
  set: function (
    propChain: string,
    newState: Record<string, unknown> | string | number
  ): void {
    if (['get', 'set'].includes(propChain)) {
      console.warn(
        `🛑 What are you doing? Should you really be changing either of these functions?`
      );
      return;
    }
    if (!propChain) return;
    propChain.split('.').reduce((original, prop, level) => {
      if (!(prop in original)) {
        original[prop] = {};
      }
      if (level === propChain.split('.').length - 1) {
        original[prop] = newState;
      }
      return original[prop];
    }, this);
  },
  // All the rest of the properties
}