Categories
JavaScript

Using a JavaScript Proxy Object to Control Object Operations

Spread the love

JavaScript object operations can be controlled using a special Proxy object

In JavaScript, a Proxy is an object that lets us control what happens when we do some operation. For example, we can use them to control the lookup, assignment, enumeration of properties or how functions are invoked.

The Proxy constructor takes 2 arguments. The first is the target, which is the object that you want to apply the controlling operations to and the second is the handler which is an object that actually controls how operations behave in the target object, also called traps.

The handler object is the object that contains traps for the Proxy. It has a number of methods to let us control the fundamental operations of the object. The object has a number of methods to trap various operations that are done by the methods in the Object constructor. They include:

  • handler.getPrototypeOf() — lets us control the behavior of the Object.getPrototypeOf() method for the target object
  • handler.setPrototypeOf()— lets us control the behavior of the Object.setPrototypeOf() method for the target object
  • handler.isExtensible()— lets us control the behavior of the Object.isExtensible() method for the target object
  • handler.preventExtensions()— lets us control the behavior of the Object.preventExtensions() method for the target object
  • handler.getOwnPropertyDescriptor()— lets us control the behavior of the Object.getOwnPropertyDescriptor() method for the target object
  • handler.defineProperty()— lets us control the behavior of the Object.defineProperty() method for the target object
  • handler.has()— lets us control the behavior of the Object.has() method for the target object
  • handler.get()— lets us control the behavior of the Object.get() method for the target object
  • handler.set()— lets us control the behavior of the Object.set() method for the target object
  • handler.deleteProperty()— lets us control the behavior of the Object.deleteProperty() method for the target object
  • handler.ownKeys()— lets us control the behavior of the Object.ownKeys() method for the target object
  • handler.apply()— lets us control the behavior of the Object.apply() method for the target object
  • handler.construct()— lets us control the behavior of the Object.construct() method for the target object

A basic example would be to return a default value for a property with the Proxy. For example, if we have the following code:

const handler = {
  get(obj, prop) {
    return prop === 'a' && obj[prop] ?
      obj[prop] :
      1;
  }
};

let p = new Proxy({}, handler);
console.log(p.a); // 1
p.a = 2;
console.log(p.a); // 2

Then the first console.log statement would output 1 and the second one would output 2. This is because in the handler object, we have a get function to modify how a property is retrieved. In the function, if the property name is a and obj[prop] is truthy, which means that obj['a'] is truthy, then we return it, otherwise, we return 1. This sets the default value of p.a where p is the Proxy object constructed by the Proxy constructor to 1. If we set a new value for p.a then the get function will return the new value since it’s truthy. Therefore, the second console.log statement of p.a outputs 2.

We can pass in an empty object for the handler argument. It would make all the default operations to the target object be forwarded as-is. For example, if we have:

let p = new Proxy({}, {});
console.log(p.a); // undefined
p.a = 2;
console.log(p.a); // 2

Then we get that the first console.log statement is undefined, but the second one is 2 because we didn’t modify the get function in the handler object to return anything if nothing is set.

Also, we can use proxies for validation of values that are assigned to an object’s properties. For example, we can use it to validate that a valid US phone number is assigned to a property of the Proxy object:

const handler = {
  set(obj, prop, value) {
    const validPhone = /^d{3}-d{3}-d{4}$/.test(value);
    if (prop === 'phoneNumber') {
      if (!validPhone) {
        throw new Error('Invalid phone number');
      }
    }

    obj[prop] = value;
    return validPhone;
  }
};

let person = new Proxy({}, handler);

person.phoneNumber = '555-555-5555'; // valid
console.log(person.phoneNumber);
person.phoneNumber = 'abc'; // throws an error

In the example above, we check that what’s being assigned is actually a valid US phone number by checking against the given regular expression. If the phoneNumber property is being assigned, then we check the regular expression against the value given in the parameters of the set function, and if the validPhone is false, then we throw an error. Otherwise, we set the value to the phoneNumber property of the object. In the end, we return the validation status of the given value. The first assignment:

person.phoneNumber = '555-555-5555';

This should work since it matches the regular expression in the set function. However, the second assignment would throw an error because it doesn’t match the regular expression given.

We can add the construct function to the handler object to extend the constructor of the target object. For example, we can extend a base object with the superclass by setting the base object’s prototype to the superclass and then create a new proxy with a handler object that has the construct and apply functions to control the behavior of the constructor and the apply functions of the base object. For example, we can write:

function extend(sup, base) {
  const descriptor = Object.getOwnPropertyDescriptor(
    base.prototype, 'constructor'
  );
  base.prototype = Object.create(sup.prototype);
  const handler = {
    construct(target, args) {
      const obj = Object.create(base.prototype);
      this.apply(target, obj, args);
      return obj;
    },
    apply(target, that, args) {
      sup.apply(that, args);
      base.apply(that, args);
    }
  };
  const proxy = new Proxy(base, handler);
  descriptor.value = proxy;
  Object.defineProperty(base.prototype, 'constructor', descriptor);
  return proxy;
}

let Person = function(name) {
  this.name = name;
};

let Boy = extend(Person, function(name, age, gender) {
  this.name = name;
  this.age = age;
  this.gender = gender;
});

let Joe = new Boy('Joe', 13, 'M');
console.log(Joe.gender);
console.log(Joe.name);
console.log(Joe.age);

This creates a proxy with the base object as a target. The handler has the constructor and the apply functions to modify the behavior of the constructor for the base object and the apply function respectively.

The construct function creates a new object obj by setting the prototype to the sup object which served as the superclass of the obj object, which in JavaScript is the same as the prototype. This is a template object which the base object inherits its members from. Then this.apply(target, obj, args); to run the constructor with the passed in arguments in the args object and then return the obj object. The apply function in the handler runs the constructor functions for both the sup and base objects to construct the base object.

Then at the end, the Proxy object is created and set as the constructor’s value with the descriptor.value = proxy; line. Then we set the base object’s prototype’s constructor by running Object.defineProperty(base.prototype, ‘constructor’, descriptor); and return the Proxy object to let us extend the constructor of the base object with a superclass object.

Below the extend function, we created a Person constructor to let us set the name property of instances of Person. Then we call the extend function with the Person object and pass in a new constructor function to with parameters for the name, age, and gender to set these properties. Then we get a new constructed object with:

let Joe = new Boy('Joe', 13, 'M');

Then when we log the properties we get ‘M’ for gender, ‘Joe’ for name, and 13 for age.

When we set one property of a Proxy object, we can simultaneously modify another property of the object. For example, if we have a room Proxy object which is constructed with a target object with the people property that has an array of names of people in the same room, and we want to push to the people array when the lastPerson property of the Proxy object is set. We do can this with the following code:

let room = new Proxy({
  people: ['Joe', 'Jane']
}, {
  get(obj, prop) {
    if (prop === 'lastPerson') {
      return obj.people[obj.people.length - 1];
    }
    return obj[prop];
  },
  set(obj, prop, value) {
    if (prop === 'lastPerson') {
      obj.people.push(value);
      obj[prop] = value;
      return true;
    }
    return true;
  }
});

console.log(room);
room.lastPerson = 'John';
console.log(room.people);
console.log(room.lastPerson);

room.lastPerson = 'Mary';
console.log(room.people);
console.log(room.lastPerson);

In the get function, we specify that the value of the lastPerson of the property will be the last element of the people array. Therefore, when we run console.log on room.lastPerson, we always get the last element of the room.people array. Otherwise, we set the object as is. In the set function, when the lastPerson property is being modified then we also push whatever value is being set into the people array in the room Proxy object. Therefore, when we run the console.log statements, we get:

["Joe", "Jane", "John"]
John

["Joe", "Jane", "John", "Mary"]
Mary

As we can see, when we set the lastPerson property of room we also get the same value pushed into the people array.

Below is a more comprehensive example of the traps we can set in the handler object to control the behavior of the proxy object’s operations:

const handler = {
  get(obj, prop) {
    return obj[prop];
  },
  set(obj, prop, value) {
    obj[prop] = value;
    return true;
  },
  deleteProperty(obj, prop) {
    delete obj[prop];
    return false;
  },
  ownKeys(obj) {
    return Reflect.ownKeys(obj);
  },
  has(obj, prop) {
    return prop in obj;
  },
  defineProperty(obj, prop, descriptor) {
    Object.defineProperty(obj, prop, descriptor)
    return true;
  },
  getOwnPropertyDescriptor(obj, prop) {
    return Object.getOwnPropertyDescriptor(obj, prop);
  },
}

let proxy = new Proxy({}, handler);
proxy.a = 1;
console.log(proxy.a);
console.log(Object.getOwnPropertyDescriptor(proxy, 'a'))
console.log(Object.defineProperty(proxy, 'b', {
  value: 1
}))
console.log('a' in proxy);
console.log(delete proxy.c);
console.log(Object.keys(proxy));

In the example above, the getOwnProperty function in the handler object controls the behavior of the Object.getOwnPropertyDescriptor() when it’s applied to the proxy object. The defineProperty function in the handler object controls how the Object.defineProperty() behaves when it’s called on the proxy object. The has function controls the behavior of the in operator, and the deleteProperty function controls the value that the delete operator returns when running with the proxy object as the operand. We returned false instead of the usual true when we use the delete operator on the proxy. The ownKeys function modifies the behavior of the Object.keys() method by enumerating the keys of an object with the Reflect object.

Wrapping up

JavaScript Proxies are a useful way to control the behavior of object operations. We can control how the object operators like the in, delete, and assignment operator behaves on the target object and its properties. This is very useful for validation during those operations and also handy for modifying the return values of operations that can return values like the in and delete operator.

We can modify the assignment operator with the set function where we can validate the value being assigned and also modify other properties at the same time. We can modify the behavior of the get function to return different values for properties in certain situations like when no value is set on a property.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *