An Introduction to JavaScript Proxies

OpenReplay Tech Blog - May 30 '23 - - Dev Community

by Craig Buckler

A "proxy" intercepts messages between you and a particular system. You've possibly encountered the term "proxy server". It's a device between your web browser and a web server that can examine or change requests and responses. They often cache assets, so downloads are faster. JavaScript proxies arrived in ES2015. A proxy sits between an object and the code that uses it. You can use them for meta-programming operations such as intercepting property updates. This article will teach you everything about proxies, so you can use them on your own.

Consider this simple JavaScript object literal:

const target = {
  a: 1,
  b: 2,
  c: 3,
  sum: function() { return this.a + this.b + this.c; }
};
Enter fullscreen mode Exit fullscreen mode

You can examine and update the numeric properties and sum their values:

console.log( target.sum() );  // 6

target.a = 10;
console.log( target.a );      // 10
console.log( target.sum() );  // 15
Enter fullscreen mode Exit fullscreen mode

JavaScript is a forgiving language, and it lets you make invalid updates which could cause problems later:

target.a = 'not a number!';
delete target.b;
target.c = undefined;
target.d = 'new property';

target.sum = () => 'hello!';

console.log( target.sum() );  // hello!
Enter fullscreen mode Exit fullscreen mode

Note: you can prevent some unwanted actions using Object methods such as .defineProperty(), .preventExtensions(), and .freeze() but they're blunt tools and won't prevent all updates.

A proxy object can intercept changes to a target object. It's defined with a handler that sets trap functions called when certain actions occur (get, set, delete, etc.) to change the behavior of the target object.

The following handler intercepts all set property operations such as myObject.a = 999. It's passed the target object, the property name as a string, and the value to set:

const handler = {

  // set property
  set(target, property, value) {

    // is value numeric?
    if (typeof value !== 'number' || isNaN(value)) {
      throw new TypeError(`Invalid value ${ value }`);
    }

    return Reflect.set(...arguments);

  }

}
Enter fullscreen mode Exit fullscreen mode

The function throws an error when the passed value is Nan or not numeric. If it's valid, Reflect executes the default object behavior -- in this case to set the target object's property (all Reflect method parameters are identical to the proxy's handler function). You could use target[property] = value; instead but Reflect makes some operations easier to manage.

You can now create a new Proxy object by passing the target and handler objects to its constructor:

const proxy = new Proxy(target, handler);
Enter fullscreen mode Exit fullscreen mode

Now use the proxy object instead of target -- the same properties and methods work as before:

console.log( proxy.sum() );  // 6

proxy.a = 10;
console.log( proxy.a );      // 10
console.log( proxy.sum() );  // 15
Enter fullscreen mode Exit fullscreen mode

But setting a non-numeric value raises a TypeError, and the program halts:

proxy.a = 'xxx'; // TypeError: Invalid value xxx
Enter fullscreen mode Exit fullscreen mode

The following code improves the Proxy handler further:

  • the set trap adds another check to ensure a property already exists and is numeric. It throws a ReferenceError when calling code attempts to set unsupported property names (anything other than a, b, or c) or override the sum() function.

  • a new deleteProperty trap throws a ReferenceError when calling code attempts to delete any property, e.g., delete proxy.a.

  • a new get trap throws a ReferenceError when calling code attempts to get a property or call a method that doesn't exist.

const handler = {

  // set property
  set(target, property, value) {

    // is it a valid property?
    if (
      !Reflect.has(target, property) || 
      typeof Reflect.get(target, property) !== 'number'
    ) {
      throw new ReferenceError(`Invalid property ${ property }`);
    }

    // is value numeric?
    if (typeof value !== 'number' || isNaN(value)) {
      throw new TypeError(`Invalid value ${ value }`);
    }

    return Reflect.set(...arguments);

  },

  // delete property
  deleteProperty(target, property) {
    throw new ReferenceError(`Cannot delete ${ property }`);
  },

  // get property
  get(target, property) {

    // is it a valid property?
    if (!Reflect.has(target, property)) {
      throw new ReferenceError(`Invalid property ${ property }`);
    }

    return Reflect.get(...arguments);

  }

}
Enter fullscreen mode Exit fullscreen mode

Examples:

proxy.a = 10;               // successful
proxy.a = null;             // TypeError: Invalid value null
proxy.d = 99;               // ReferenceError: Invalid property d
proxy.sum = () => 'hello!'; // ReferenceError: Invalid property sum
delete proxy.a;             // ReferenceError: Cannot delete a
console.log( proxy.e );     // ReferenceError: Invalid property e
Enter fullscreen mode Exit fullscreen mode

Validating types is not the most interesting use of proxies, and should you require type support, perhaps you should consider TypeScript! We'll examine a more advanced example below.

Proxy trap types

A Proxy allows you to intercept actions on a target object. The handler object defines trap functions. In most cases, you'll be using get or set, but the following traps support more advanced use:

Called when creating an object with the new operator.

Called when examining a property or running a method.

Called when setting a property with a value. Returning false throws a TypeError exception.

Called when using Object.defineProperty() to create or update a property. It must return true (successfully defined) or false (could not be defined).

Called when deleting a property. It must return either true (deleted) or false (not deleted).

Called when executing a target object, which is a function (it's not called for functions defined as object methods).

Called when using a static method such as in, e.g., 'p' in object. It must return either true (defined) or false (not defined).

Called when using Object.keys(). It must return an array of enumerable string-keyed property names, such as ["a", "b", "c"].

Called when using Object.isExtensible() to check whether the object permits new properties. It must return true or false.

Called when using Object.preventExtensions() to stop the object permitting new properties. It must return true or false.

Called when using Object.getOwnPropertyDescriptor() to return an object describing the configuration of a specific property. It must return an appropriate object with value, writable, configurable, enumerable, get, and set properties.

Called when getting the object prototype using `Object.getPrototypeOf().

Called when setting the object prototype using `Object.setPrototypeOf().

All traps have an associated Reflect() method with identical parameters, so it's not necessary to create your own implementation code when you require the default behavior. For example:

const handler = {

  // trap property descriptor
  getOwnPropertyDescriptor(target, property) {

    console.log(`examining property ${ property }`);

    return Reflect.getOwnPropertyDescriptor(...arguments);

  }

};
Enter fullscreen mode Exit fullscreen mode

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.

OpenReplay

Happy debugging! Try using OpenReplay today.


Two-way data binding with a Proxy

Data binding synchronizes two or more disconnected objects. In this example, updating a form field changes a JavaScript object's property and vice versa.

View the demonstration Codepen...

(Click the SUBMIT button to view the object's properties in the Codepen console.)

The HTML has a form with the ID myform and three fields:

<form id="myform" action="get">

  <input name="name" type="text" />
  <input name="email" type="email" />
  <textarea name="comments"></textarea>

</form>
Enter fullscreen mode Exit fullscreen mode

The JavaScript code creates a myForm object bound to this form by passing its node to a FormBinder() factory function:

// form reference
const myformElement = document.getElementById('myform');

// create 2-way data form
const myForm = FormBinder(myformElement);
Enter fullscreen mode Exit fullscreen mode

From that point onwards, the object's properties return the current state of the associated field:

myForm.name;      // value of name field
myForm.email;     // value of email field
myForm.comments;  // value of comments field
Enter fullscreen mode Exit fullscreen mode

You can update the same properties, and the associated form field will change accordingly:

myForm.name = 'Some Name'; // update name field
Enter fullscreen mode Exit fullscreen mode

live field update

The implementation defines a FormBind class. The constructor examines all field elements in the passed form, and if they have a name attribute, it sets a property of that name to the field's current value. A private #Field object also stores the field's name and DOM node for later use.

// form binding class
class FormBind {

  #Field = {};

  constructor(form) {

    // initialize object properties
    const elements = form.elements;
    for (let f = 1; f < elements.length; f++) {

      const field = elements[f], name = field.name;
      if (name) {
        this[name] = field.value;
        this.#Field[name] = field;
      }

    }
Enter fullscreen mode Exit fullscreen mode

An input event handler triggers whenever a form field changes. It checks whether the #Field reference exists and updates the associated property with its current value.

    // form change events
    form.addEventListener('input', e => {

      const name = e.target.name;
      if (this.#Field[ name ]) {
        this[ name ] = this.#Field[ name ].value;
      }

    });
Enter fullscreen mode Exit fullscreen mode

An updateValue() method is then defined, which updates both the object property and the HTML field when passed a valid property and newValue:

  // update property and field
  updateValue(property, newValue) {

    if (this.#Field[ property ]) {

      this[ property ] = newValue;
      this.#Field[ property ].value = newValue;
      return true;

    }

    return false;

  }

}
Enter fullscreen mode Exit fullscreen mode

To call this method, a Proxy handler defines a single set trap that intercepts a property update:

// form proxy traps
const FormProxy = {

  // intercept set
  set(target, property, newValue) {
    return target.updateValue(property, newValue);
  }

};
Enter fullscreen mode Exit fullscreen mode

A proxy factory function then provides an easy way to create an object which is bound to an HTML form:

// form 2-way data binder
function FormBinder(form) {
  return form ? new Proxy(new FormBind(form), FormProxy) : undefined;
}


// form node
const myformElement = document.getElementById('myform');

// create 2-way data form
const myForm = FormBinder(myformElement);

myForm.name = "Some Name";
Enter fullscreen mode Exit fullscreen mode

While this is not production-level code, it illustrates the usefulness of JavaScript Proxies. If you want to develop it further, you can add further code to handle the following:

  • unusual field names which would not be valid property names, such as my-name or my.name
  • checkbox, radio, and select fields, and
  • dynamic HTML DOM updates which add or remove form fields.

Conclusion

Proxy support is available in all modern browsers and JavaScript runtimes, including Node.js and Deno. They'll only be a problem if you have to support Internet Explorer 11 since there's no way to polyfill or transpile ES6 proxy code to ES5.

Proxies won't be necessary for all your applications, but they provide some interesting opportunities for metaprogramming. You can write programs that analyze or transform other programs or even modify themselves while executing.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .