What is a JavaScript Proxy?

Usually, when it comes to the JavaScript language, we are talking about the new features provided by the ES6 standard and this article will not be an exception. We will talk about JavaScript proxies and how they can be helpful, but before we dive into the examples, let's define what are they.

The MDN definition says that The proxy object is used to define custom behavior for fundamental operations (e.g. property lookup, assignment, enumeration, function invocation, etc).

In more simple terms, we can say that a proxy object is a wrapper for our target object where we can manipulate its properties and block the direct access to it. You might find it difficult to integrate them in a real-world application, but I encourage you to go through the concept, it can potentially change your perspective.

The Concept

A proxy object has three main terms we should be aware of:

  • handler - an object which contains traps
  • traps - the methods that provide property access (I see these as the methods we can override within the handler object)
  • target - the object which the proxy virtualizes (The actual object which is wrapped and manipulated by the proxy object)

In this article, I will provide simple use-cases for get and set traps considering that at the end of it, we will see how we can use them and wrap more complex functionalities, like API.

The Syntax and Examples

let proxy = new Proxy(target, handler);

That is it. All we need to do is to pass in the target and handler objects to the constructor, and the proxy will be created. Now, let's see how we can take advantage of it. To make it easier to point out the benefits, first, we need to write some code without it.

Imagine that we have a user object with a couple of properties and that we want to print the user information if the property exists, and if it doesn't, throw an exception. To do this, the value first must be retrieved and then manually checked within the code. Without the proxy object, this code resides within our function responsible for the logic, which is to print the user information and we don't want that.

let user = {
  name: 'John',
  surname: 'Doe'
};

let printUser = (property) => {
  let value = user[property];
  if(!value) {
    throw new Error(`The property [${property}] does not exist`);
  } else {
    console.log(`The user ${property} is ${value}`);
  }
}

printUser('name'); // outputs 'The user name is John'
printUser('email'); // throws an error: The property [email] does not exist

The get trap

By looking at the code above, you might agree with me that it would be useful to move the condition and exception throwing somewhere else and to focus only on the actual logic which is displaying the user information. This is where we can use the proxy object. Let's update the example.

let proxy = new Proxy(user, {
  get(target, property) {
    let value = target[property];
    if(!value) {
      throw new Error(`The property [${property}] does not exist`);
    }
    return value;
  }
});

let printUser = (property) => {
  console.log(`The user ${property} is ${proxy[property]}`);
};

printUser('name'); // outputs 'The user name is John'
printUser('email'); // throws an error: The property [email] does not exist

In the example above, we've wrapped the user object and introduced a get trap. This trap acts as an interceptor and before returning the value it does the required check if the property exists and if it doesn't, throws an exception.

The output is the same as in the first case, but now our function printUser focuses on the logic and only handles the message.

The set trap

Another good example where proxies can be useful is the property value validation. In this case, we need to use the set trap and inside of it do the validation. A set trap intercepts the process of setting the property value. Quite a useful hook when we need to ensure the target type, for example. Let's see it in practice.

let user = new Proxy({}, {
  set(target, property, value) {
    if(property === 'name' && Object.prototype.toString.call(value) !== '[object String]') { // ensure string type
      throw new Error(`The value for [${property}] must be a string`);
    };
    target[property] = value;
  }
});

user.name = 1; // throws an error: The value for [name] must be a string

As you might notice while reading the examples, we've used an existing object in the first, and an empty object in the second example as a proxy target. In the first case, we need to point out that the original object will change and the proxy does not behave like a copy. An important thing to be aware of.

These were fairly simple use-cases, and there are more of them where proxies can come in handy:

  • Formatting
  • Value and type correction
  • Data binding
  • Debugging
  • ...

Now it's time to create a little more complex use-case.

API with Proxies - A More Complex Example

By using the knowledge from the simple use-cases, we could create an API wrapper to be used across our application. I've added the support only for post and get requests, but it can be easily extended. The code looks something like below.

const api = new Proxy({}, {
  get(target, key, context) {
    return target[key] || ['get', 'post'].reduce((acc, key) => {
      acc[key] = (config, data) => {

        if (!config && !config.url || config.url === '') throw new Error('Url cannot be empty.');
        let isPost = key === 'post';

        if (isPost && !data) throw new Error('Please provide data in JSON format when using POST request.');
        
        config.headers = isPost ? Object.assign(config.headers || {}, { 'content-type': 'application/json;chartset=utf8' }) : 
            config.headers;

        return new Promise((resolve, reject) => {
          let xhr = new XMLHttpRequest();
          xhr.open(key, config.url);
          if (config.headers) {
            Object.keys(config.headers).forEach((header) => {
              xhr.setRequestHeader(header, config.headers[header]);
            });
          }
          xhr.onload = () => (xhr.status === 200 ? resolve : reject)(xhr);
          xhr.onerror = () => reject(xhr);
          xhr.send(isPost ? JSON.stringify(data) : null);
        });
      };
      return acc;
    }, target)[key];
  },
  set() {
    throw new Error('API methods are readonly');
  },
  deleteProperty() {
    throw new Error('API methods cannot be deleted!');
  }
});

First, let's explain the simple traps implementation, set and deleteProperty. We've added a protection level and made sure that, whenever someone, accidentally or not, tries to set a new value to any of the API properties, an exception gets thrown.

Considering that the deleteProperty trap wasn't explained earlier I need to point out that this trap is executed every time when we try to delete a property. This is another protection which made sure that no-one can delete any property from our proxy. We don't want to lose our API methods.

The get trap is the interesting one here and it does a couple of things. Initially, our target is an empty object because this trap will create all the methods the first time someone uses the API by reducing an array of supported handlers to the required format. Within the reduce callback we do the validation and checks required by the API specification against the provided config. In this example, we do not allow empty URLs and post requests without provided data. These checks can be extended and modified, but the important thing is that we can centralize and invoke them only at this one place.

The reducing is done only upon the first API invocation. Every next time an entire reduce process is skipped and the get trap simply does the default behavior and returns the property value which is an API handler. Each handler returns a Promise object responsible for creating the request and calling the server.

Example Usage:

api.get({
  url: 'my-url'
}).then((xhr) => {
  alert('Success');
}, (xhr) => {
  alert('Fail');
});

Conclusion

Proxies can come in handy when you need more control over the metadata, so to speak. You can spy on your objects and ensure the correct behavior by extending or denying access to the original based on your controlled rules.

If you liked this post, subscribe here or follow me on twitter to stay tuned. Feel free to express your thoughts in the comment section below. And, if you are thirsty for more, consider buying me a coffee to keep me awake late at night while writing.

Thank you for reading, and see you in the next post.