Back

An Introduction to JavaScript Proxies

An Introduction to JavaScript Proxies

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; }
};

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

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!

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);

  }

}

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);

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

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

proxy.a = 'xxx'; // TypeError: Invalid value xxx

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);

  }

}

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

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:

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);

  }

};

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

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>

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);

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

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

myForm.name = 'Some Name'; // update name field

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;
      }

    }

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;
      }

    });

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;

  }

}

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);
  }

};

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";

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.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs and track user frustrations. Get complete visibility into your frontend with OpenReplay, the most advanced open-source session replay tool for developers.

OpenReplay