Photo by Suzanne D. Williams on Unsplash
An Introduction to JavaScript Proxies
How the power of Proxies can change the way you approach JavaScript programming.
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 which 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.
Proxy quick start
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 aReferenceError
when calling code attempts to set unsupported property names (anything other thana
,b
, orc
) or override thesum()
function.a new
deleteProperty
trap throws aReferenceError
when calling code attempts to delete any property, e.g.delete proxy.a
.a new
get
trap throws aReferenceError
when calling code attempts to get a property or call a method which doesn't exist.
const handler = {
// set property
set(target, property, value) {
// is 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 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:
-
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 aTypeError
exception. defineProperty(target, property, descriptor)
Called when using
Object.defineProperty()
to create or update a property. It must returntrue
(successfully defined) orfalse
(could not be defined).deleteProperty(target, property)
Called when deleting a property. It must return either
true
(deleted) orfalse
(not deleted).apply(target, thisArg, argList)
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 eithertrue
(defined) orfalse
(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 returntrue
orfalse
. -
Called when using
Object.preventExtensions()
to stop the object permitting new properties. It must returntrue
orfalse
. getOwnPropertyDescriptor(target, property)
Called when using
Object.getOwnPropertyDescriptor()
to returns an object describing the configuration of a specific property. It must return an appropriate object withvalue
,writable
,configurable
,enumerable
,get
, andset
properties.-
Called when getting the object prototype using
Object.getPrototypeOf()
. setPrototypeOf(target, prototype)
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);
}
};
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
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 the field's 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 which 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 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:
unusual field names which would not be valid property names, such as
my-name
ormy.name
checkbox, radio, and select fields, and
dynamic HTML DOM updates which add or remove form fields.
Proxy power
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 in all your applications but they provide some interesting opportunities for metaprogramming. You can write programs which analyze or transform other programs or even modify themselves while executing.