While solutions to many of our technical problems are just an npm install away, the same isn’t true when facing the problem domain that makes each project special. But, although the details of these problems may wildly vary, they often follow similar patterns. By identifying and applying these patterns in our code, we are able to implement more robust and flexible solutions.
Back in the old century, when the web was young, a magical group of geeks gathered to decree a set of incantations - promising riches in the form of decoupling, flexibility, and maintainability. They called these spells design patterns.
Although they were originally designed for statically typed, less malleable languages, we can still apply these patterns in JavaScript and reap the benefits of loose coupling and flexibility. Now let’s take a look at an incredibly simple, yet extremely versatile pattern: the Proxy.
The Proxy
A Proxy is an object that stands in for another object. Think of it as a surrogate. A Proxy can masquerade as another object, intercept messages, control access and modify output. With a Proxy, you can implement additional logic to control your interaction with an object without bloating the original object and violating the Single Responsibility Principle. It also allows more flexibility for the role of an object: Need your user object to have different roles based on permissions? Try wrapping it in a different Proxy.
Are you ready for the coolest part? ES6 has a built-in Proxy object! Let’s take a look at a few use cases.
Modifying Output
We often have to consume data from third party APIs, over which we have little control of the format. Many times we need to perform transformations on this data. By wrapping it in a Proxy
, we can transform data on the fly.
Below, we create a new Proxy
, which accepts two arguments: the original target object and a handler object. The handler implements a get
function, which is one of many trap methods available, allowing us to intercept access to properties of the original target object. Then, we just capitalize the value and return the modified output.
// start with your original target object…
const target = {
firstName: 'ada',
lastName: 'lovelace'
};
// ...and define a handler to wrap the object…
const handler = {
get(target, key) {
const value = target[key];
return value.charAt(0).toUpperCase() + value.slice(1);
}
};
// ...then create a Proxy with your target and handler
const person = new Proxy(target, handler);
console.log(`${person.firstName} ${person.lastName}`);
// Output: Ada Lovelace
Type Checking and Property Validations
Dynamic types are awesome. Except when they’re not. Sometimes, only a specific type will do. We can use a Proxy to enforce type on an object’s properties when they are set. We can use it to validate the values being set as well.
const handler = {
set(target, prop, value) {
// if the value passes validation, set it on the target object
if (prop in this.validations && this.validations[prop](value)) {
target[prop] = value;
return true;
}
// if a value does not pass validation, return false
// in strict mode this raises a TypeError,
// otherwise it fails silently
return false;
},
// a set of validations for specific properties
// if a property doesn't exist in this list it won't be set
validations: {
quantity: (val) => Number.isInteger(val) && val <= 5,
color: (val) => ['red', 'blue'].includes(val),
}
};
const product = new Proxy({}, handler);
// these values will be set
product.quantity = 5;
product.color = 'blue';
// these values won't be set
product.quantity = 'string';
product.quantity = 6;
product.color = 'green';
product.anything = 'anything';
This time, we use the set
trap. In this function, we can inject any logic we need to determine whether to set a specific property. The set
function expects us to return a boolean based on whether the property was set. In strict mode, returning false will throw a TypeError.
Performance Profiling
When performance tuning an application, it can be useful to profile the behavior of specific objects. With a Proxy
and the help of the Performance API, we can intercept and profile method calls for an object:
// the original object that we want to profile
const originalObject = {
loop(count) {
// a potentially long running method
// the details don't matter much here
let result = 0;
for (let i=0; i < count; i++){ result = result + (i * i); }
return result;
}
}
const handler = {
get(target, key) {
const origMethod = target[key];
if (!(origMethod instanceof Function)) return origMethod;
// return a new function that calls the old function,
// takes a performance measurement for the function,
// and returns the result of the original function call
return function (...args) {
const start = `start ${key}`;
const end = `end ${key}`;
performance.mark(start);
const result = origMethod.apply(target, args);
performance.mark(end);
performance.measure(key, start, end);
return result;
};
}
}
// create a proxy object to stand in place of the original object
const proxy = new Proxy(originalObject, handler);
// now the proxy object behaves like the original object,
// but any method call will be timed by the performance API
proxy.loop(10000);
proxy.loop(1000000);
proxy.loop(100000000);
console.log(performance.getEntriesByName("loop"));
/* console output:
(3) [PerformanceMeasure, PerformanceMeasure, PerformanceMeasure]
0: PerformanceMeasure {name: "loop", entryType: "measure", startTime: 17062.895, duration: 0.2649999999994179}
1: PerformanceMeasure {name: "loop", entryType: "measure", startTime: 17063.185, duration: 4.860000000000582}
2: PerformanceMeasure {name: "loop", entryType: "measure", startTime: 17068.08, duration: 138.63500000000204}
*/
Notice that, in our get
trap, we have to treat function calls differently than properties. Because we are intercepting a function, we need to return a function. This function calls the original function and returns the original result (via the apply method), but we set timing marks before and after the function call. This allows us to profile the time it takes for the original function to execute.
Note: This profiling method won’t capture time spend on asynchronous calls.
So what’s the catch?
As with many ES6 features, support for older browsers is an issue. But unlike many features, a complete polyfill cannot be implemented due to limitations of ES5. However, there are some options available.
It’s also important to consider performance when deciding whether to implement an ES6 Proxy
in production code. Accessing properties on a Proxy
is slower than on a plain JavaScript object. Consider whether the flexibility is worth the performance trade-off for your use case.
Wrapping Up
The Proxy is one of many patterns that allows us to write more flexible and expressive code. By wrapping an object in a Proxy, we can control and modify interactions with that object. This is a simple, but powerful concept, and one that allows a wide variety of use cases. The examples in this article are the tip of the iceberg. Consider the ways that a Proxy, along with other design patterns, can help improve your codebase.