Meta Programming in JavaScript with Proxies

Ryan Dabler
ITNEXT
Published in
10 min readJan 19, 2019

--

Previously, I wrote an article about customizing objects by manually defining its property/accessor descriptors. The current article is, in spirit, a continuation of that article in that I will assume some knowledge of descriptors as I compare them to what we can get with proxies. I recommend reading my prior article if there are any questions about descriptors and their own benefits.

Before I learned JavaScript I toyed around with Python for a couple years. One of the really nice things about Python is that it tries hard to be developer-friendly, and one of the ways it shines is by making lists easy to work with. A common developer need with any array-like data structure is the ability to access elements with respect to the end of the array (not just the beginning). Fortunately in Python it is as simple as writing some_python_list[-1].

Imagine my surprise when I learned JavaScript and tried to get the last element in the array using the same method:

javaScriptArray[-1]; // undefined

Really? I can’t use this method to get the last element in the array? Can I use a Ruby-like approach instead?

javaScriptArray.last; // undefined

No. Unfortunately, to get the last element we still have to do the clunky javaScriptArray[ javaScriptArray.length - 1 ] technique. This unfortunately makes code more verbose (and therefore a little harder to read) and has always seemed like a major oversight in the ECMAScript standard.

However, while negative indexing is not a feature we can use out of the box for now, JavaScript does have a very handy feature that will allow us to create such behavior (and so much more!) ourselves: Proxies.

Proxies

A proxy is an object that serves as a stand-in between the code being executed and the actual object being accessed. The main purpose for proxies is to expose an interface which intercepts various operations we desire to perform on the object and in some way modify the default behavior of that operation to produce an outcome we would not normally get.

This ability to modify code execution is called meta programming and it affords the JavaScript developer quite a lot freedom in making minor to drastic changes in behavior. These deviations come with the trade-off that certain requirements (called “invariant conditions”) be met, having the goal of not allowing the proxy’s code-bending to drift too far from what would be expected had those operations been performed on a typical JavaScript object.

Some of the best use cases for using proxies is to allow for various side effects to be performed (e.g., console logging, API calls) or to extend default behavior like getting/setting properties. To illustrate some of these concepts, we will continue the database example from my previous article on property descriptors, although instead of handling one row of a database, we will work with the database itself.

While proxies can intercept a large number of operations (called “traps”), the ones we will illustrate are:

  • has
  • get
  • set
  • ownKeys
  • deleteProperty

Setting Up the Example

As our example is an extension of our database system from before, we will create a simple array with some dummy data.

const db = [
{
name: 'John Doe',
age: 28,
lastModified: Date.now(),
lastAccessed: Date.now()
},
{
name: 'Jane Smith',
age: 30,
lastModified: Date.now(),
lastAccessed: Date.now()
},
{
name: 'Albert Einstein',
age: 52,
lastModified: Date.now(),
lastAccessed: Date.now()
}
];

Of course right now we can’t do anything other than what a typical array could do. We can’t use negative indices, getting and setting properties is entirely based on JavaScript’s out-of-the-box [[Get]] and [[Set]] specification, and retrieving the array’s keys will essentially give us a list of numerical indices.

We are going to change all that. Our goal is to implement the following behaviors:

  • Allow negative indices
  • Get and set certain properties in a different way than we are used to
  • Easily retrieve the set of keys used by the rows of the database (and not the numerical indices of our rows)
  • Determine whether a row exists in our database without having to explicitly call Array.prototype.find()
  • Delete properties off the rows of our database using a simple delete command

Proxying Our Database

Creating a proxy is easy:

const dbProxy = new Proxy(db, {});

We pass two parameters into our new Proxy() call: the object to be proxied (our sample database above) and the handler object, specifying what we want to trap. Whatever trap we don’t specify in this object means we want the JavaScript engine to use the default behavior.

So let’s build out our custom features.

`get`

Since we want to use negative indices, we can create a trap called get that will translate negative indices to their positive counterpart:

const handler = {
get(tgt, prop, rcvr) {
const _prop = +prop;
if (Number.isInteger(_prop) && _prop >= 0)
return tgt[_prop];
if (Number.isInteger(_prop) && _prop < 0)
return tgt[tgt.length + _prop];

return tgt[prop];
}
};

We cast prop to a number and check if the result is an integer (rather than a float or, if prop is alphanumeric, a NaN) and rewrite negative properties to be positive. If we pass an alphanumeric property we merely treat it as if we were trying to access a property normally. Had we not specified return tgt[prop] at the end, we would be unable to access any of our target array’s other properties like length.

Two quick notes on the parameters specified for our get trap:

  1. tgt represents the underlying object we are proxying, prop the property we are accessing, and rcvr the receiving proxy. So if we said dbProxy[-1] our parameters would end up being db, '-1', dbProxy, respective to the order listed in our trap. I will use the shorthand tgt and rcvr to help keep the code concise.
  2. We must be careful when accessing a property on rcvr in a get trap. This is because rcvr is the proxy itself, so if we access one of its properties in the trap, we re-enter the get trap, and we run the risk of infinitely recursing into this handler until the JavaScript engine throws a callstack error.

More `get`

There are two more things that we should show off about proxies: we want to update our lastAccessed field any time we access one of its properties and specify a property last that retrieves the last row in our database.

const handler = {
get(tgt, prop, rcvr) {
const _prop = +prop;

if (prop in tgt) tgt[prop].lastAccessed = Date.now();
if (Number.isInteger(_prop) && _prop >= 0)
return tgt[_prop];

if (Number.isInteger(_prop) && _prop < 0) {
const posProp = tgt.length + _prop;
if (posProp in tgt)
tgt[posProp].lastAccessed = Date.now();
return tgt[posProp];
}
if (prop === 'last') return rcvr[-1];

return tgt[prop];
}
};

Now, any time we access a property, we immediately set lastAccessed directly on the row we are accessing (assuming that our property actually in the array) and if we ever call dbProxy.last, we simply access the -1 property on our proxy (which then gets trapped again but this time resolves to getting one of our rows directly off the underlying tgt array so we avoid infinite recursion).

`set`

Typically an object’s properties can be changed fairly easily and at whim, merely by executing obj.prop = val. It is possible to prevent properties from being changed by calling functions like Object.preventExtensions() or Object.freeze(), but these are more all or none approaches and don’t come with granule control over what can be changed.

With our proxy we are going to lock down adding any new properties unless we call dbProxy.new = ..., as well as add the ability to custom sort our database.

const handler = {
set(tgt, prop, val, rcvr) {
if (prop === 'sort') {
const { by, dir } = val;
tgt.sort(
(a, b) => dir === 'asc'
? (a[by] < b[by] ? -1 : 1)
: (b[by] < a[by] ? -1 : 1)
);
}

if (prop === 'new') {
tgt.push({
...val,
lastModified: Date.now(),
lastAccessed: Date.now()
});
}

return true;
}
};

Now we can try to add a new row to our database by running

dbProxy.push({ name: 'Test', age: 100 });

and see no change to our database, even though calling dbProxy.new = ... will give us the desire result. We can also sort our database by any of its fields simply by calling dbProxy.sort = { by: 'age', dir: 'asc' }.

`has`

Suppose we want to be able to tell if our database has any rows which match particular criteria. Normally, we would do .find() on the array and pass in a suitable function. Instead, however, we want to simply say something like { name: 'Albert Einstein' } in dbProxy and get a true or false without having to always write a function.

Normally in requires us to check that a string exists as a direct key on our object, but the trap allows us to write any existence-checking logic we want. Unfortunately, we are constrained to using a string on the left hand side of the operator, so we can’t supply the object we listed above. So we will suffice with converting it first to JSON.

const handler = {
has(tgt, prop) {
const obj = JSON.parse(prop);
const criteria = Object.entries(obj);

return tgt.some(obj =>
criteria.every( ([key, val]) =>
obj[key] === val
)
);
}
};

We convert the string (parameter prop) to an object and then convert it to an array of entries and check that at least one document of our underlying array (parameter tgt) has a document that matches all the criteria.

const johnDoe = JSON.stringify({ name: 'John Doe' });
const albertEinstein = JSON.stringify(
{ name: 'Albert Einstein', age: 50 }
);
johnDoe in dbProxy; // true
albertEinstein in dbProxy; // false: ages don't match

`ownKeys`

Because our database is a document-style database with no strict schema we might not know all the keys currently in use if later documents were added with different keys than earlier (or vice versa). So we can trap calls like Object.keys() or Reflect.ownKeys() to retrieve the columns used in our database rows (rather than the typical numerical indices of an array).

const handler = {
ownKeys(tgt) {
const keys = tgt.reduce(
(keys, doc) => {
const docKeys = Object.keys(doc);
docKeys.forEach(key => {
keys.add(key);
});

return keys;
},
new Set()
);

return [ ...keys, 'length' ];
}
};

This trap happens to be one of the strictest we could use in terms of what values can (and must) be returned versus what we might want to return.

We have to return only strings or symbols, and any property that is a non-configurable own property must be in the list — hence the return of 'length' in our array, which of course isn’t used in any of our rows but whose omission throws a TypeError.

Furthermore, Reflect.ownKeys() will give us back the expected result while Object.keys() only returns an empty array, so use of this trap requires us to be mindful regarding which key-grabbing functions are being used on the proxy.

`deleteProperty`

We will use the deleteProperty trap not to delete a property off of our proxy itself (essentially deleting an entry from our database array) but to delete a property off each of our rows in the database. So if we decided we no longer wanted to have a lastModified field we could prune our database simply by calling delete dbProxy.lastModified.

const handler = {
deleteProperty(tgt, prop) {
const keys = this.ownKeys(tgt).slice(0, -1);

if (keys.includes(prop)) {
tgt.forEach(row => {
delete row[prop];
});

return true;
}

return false;
}
};

There is an interesting feature being used in this trap. In the context of proxy handlers, this is bound to the handler itself, so assuming we have defined other traps, we can use them in a particular trap by calling this.some_trap().

In our example we are assuming we have defined the ownKeys trap as defined in the previous section, so we can get the keys that make up our database schema, minus the 'length' property (hence, the .slice(0, -1) call). And if the property we are trying to delete is actually used in our database we delete that property off of each row and return true for success. Otherwise we return false to indicate it can’t be done.

Comparing Proxies with Descriptors

There are many comparisons we can draw between what we can do with proxies and what we can do with property/accessor descriptors, in part because both open up many of the same doors by:

  • allowing side effects in our operations
  • preventing operations from happening
  • customizing behavior of certain operations

So, what could we have done with descriptors versus proxies?

We technically could have implemented negative indices with accessor descriptors. This would have required a large amount of overhead to maintain the mapping of negative indices to their positive counterpart whenever the underlying array changed, whereas with proxies we remap based on a mathematical equation. Furthermore, using descriptors would mean that the negative indices would actually be keys on the array rather than purely phantom keys on a proxy.

What we did using our set trap could mostly have been done using descriptors. Sorting the array by setting on the .sort property and the way we inserted on the .new property could easily be done without proxies. Yet they would not have allowed us to nullify all other set operations across the board without invoking helper functions like Object.freeze().

All the other traps we used on our proxy simply could not be done any other way. This is obvious when we consider that the purpose of proxies are to provide a means to meta program and fundamentally change typical code execution behavior, while descriptors are simply a feature to allow better control over an object during normal programming.

Conclusion

We’ve seen the power we can exert when using proxies to intercept and modify typical JavaScript operations. While proxies don’t intercept every command (e.g., we can’t overload mathematical operators), they do intercept enough to be extremely useful.

What we could have implemented using the above traps is endless (for example, we didn’t get around to implementing slices like array[1:3] that are so common in Python) and we weren’t able to illustrate some of the other very important traps that are exposed by proxies — mostly because they are functional-based traps and we were dealing only with objects. They are important enough that they deserve their own discussion anyway.

I’ll leave this Gist as a complete example of the proxy we put together in this article.

--

--

I’m a fullstack web developer working with the MERN stack. I love learning new things, and have decided writing about them is the best way to remember them.