One feature of the current JavaScript language standard, ECMAScript 5,
that hasn't gotten nearly enough attention is the
Object.defineProperty()
function.
This function is what makes modules like
mongoose possible. If you've never used
Object.defineProperty()
before, here's a simple use case: suppose you wanted
to be able to increment and decrement a JavaScript date's month property
using the ++
and --
operators.
var d = new Date();
Without Object.defineProperty()
this task would be impossible outside of
functions. With Object.defineProperty()
, this is easy:
var d = new Date();
// Thu Apr 23 2015 19:23:46 GMT-0300 (UYT)
console.log(d);
Object.defineProperty(d, 'month', {
get: function() {
return d.getMonth();
},
set: function(v) {
d.setMonth(v);
}
});
++d.month;
// Sat May 23 2015 19:23:46 GMT-0300 (UYT)
// Exactly one month later!
console.log(d);
You may be wondering why I'm discussing Object.defineProperty()
in a post
about proxies. Turns out, proxies are just a more general form of
Object.defineProperty()
.
What are Proxies?
In the previous section, you saw some of the cool syntactic tricks that
Object.defineProperty()
makes possible. The Object.defineProperty()
function
is just one way you can hook in to the
ECMAScript GetProperty()
method.
If you're not the kind of person that peruses the ECMAScript spec for fun,
just be aware that when you do a property access, like obj.prop
, the
JavaScript interpretter allows you to hook functions into this property access.
Proxies are the natural extension to Object.getProperty()
. Note that
Object.defineProperty()
requires you to specify the property name. Proxies
allow you to define a catch-all function that executes for all property
accesses on an object. A basic cute example of how proxies work is an object
that enables you to convert a string to uppercase with a property access:
var uppercase = Proxy.create({
get: function(proxy, key) {
return key.toUpperCase();
}
});
// Outputs "A"
console.log(uppercase.a);
// Outputs "B"
console.log(uppercase.b);
Note The above code will only run in Node 0.12 with the --harmony_proxies
flag. The --harmony
flag is not sufficient to enable proxies in NodeJS.
Using Setters with Proxies
Proxies are not just getter-only, you can also define a set function which
integrates with assignment operators (as well as increment and decrement).
Setters are tricky with proxies though, because a proxy getter executes for
every property access. Thus, a proxy getter that accesses this
is actually
recursive and so it's easy to trigger an infinite recursion.
Because of the infinite recursion, typically you don't want to use this
in a proxy setter. The most simple choice is to use a proxy as a wrapper around
a non-proxy object in the proxy's scope. For instance, let's say you want to
store an array of dates, but you want to be able to interact with the dates as
if they were strings.
var moment = require('moment');
var arr = [];
var p = Proxy.create({
get: function(proxy, key) {
var i = parseInt(key, 10);
if (isNaN(i)) {
return arr[key];
}
if (arr[i]) {
return arr[i].format('YYYY-MM-DD');
}
return arr[i];
},
set: function(proxy, key, value) {
var i = parseInt(key, 10);
if (isNaN(i)) {
return arr[key] = value;
}
arr[i] = moment(value, 'YYYY-MM-DD');
}
});
p[0] = '2011-06-01';
// Prints "string"
console.log(typeof p[0]);
// Prints "object"
console.log(typeof arr[0]);
// Prints "2011-06-01 is a Wednesday"
console.log(p[0] + ' is a ' + arr[0].format('dddd'));
p[0] = '2012-06-01';
// Prints "2012-06-01 is a Friday"
console.log(p[0] + ' is a ' + arr[0].format('dddd'));
In the above example the proxy p
sits in front of the array of
moment objects arr
. Any set operation on the
proxy gets translated by the set
function above into setting a moment
object in arr
.
Note if you declare an array with var arr = [1];
and access using a
numeric string, JavaScript will convert it to a number. For instance,
arr['0'] === arr[0]
. That's why the parseInt()
logic above gives the
correct behavior. I'm not entirely sure why this is the case and I'd be quite
happy if somebody could point me to the relevant part of the ECMAScript spec.
Edit Sept 25, 2015: According to section 6.1.7 of the ES6 spec, array indexes are always interpretted as strings.
Proxies Gotchas
Here's an interesting question - what happens when you call
require('util').inspect()
on the proxy p
in the previous example? If you
guessed "node crashes horribly with a baffling error message", you'd be
right.
util.js:236
var keys = Object.keys(value);
^
TypeError: undefined is not a function
at Function.keys (native)
at formatValue (util.js:236:21)
at Object.inspect (util.js:147:10)
Object.keys(p)
crashes out with a very strange error - Object.keys
is
definitely not undefined. This is a problem with how the proxy was implemented.
Because a proxy has full control over property accesses, there's no way for
node to figure out what properties the proxy has other than trying every
possible string. To be able to inspect the proxy properly, you need to define
the getOwnPropertyNames
and getOwnPropertyDescriptor
"traps":
var p = Proxy.create({
get: function(proxy, key) {
var i = parseInt(key, 10);
if (isNaN(i)) {
return arr[key];
}
if (arr[i]) {
return arr[i].format('YYYY-MM-DD');
}
return arr[i];
},
set: function(proxy, key, value) {
var i = parseInt(key, 10);
if (isNaN(i)) {
return arr[key] = value;
}
arr[i] = moment(value, 'YYYY-MM-DD');
},
// Return a list of property names
getOwnPropertyNames: function() {
return Object.keys(arr);
},
// Return the descriptor for the given property. Note that the first parameter
// is **not** 'proxy', at least in NodeJS
getOwnPropertyDescriptor: function(key) {
return Object.getOwnPropertyDescriptor(arr, key);
}
});
With these two traps, require('util').inspect(p);
works as you might expect,
modulo the fact that p
is not an array.
{ '0':
{ [Number: 1338519600000]
_isAMomentObject: true,
_i: '2012-06-01',
_f: 'YYYY-MM-DD',
_isUTC: false,
_pf:
{ empty: false,
unusedTokens: [],
unusedInput: [],
overflow: -1,
charsLeftOver: 0,
nullInput: false,
invalidMonth: null,
invalidFormat: false,
userInvalidated: false,
iso: false },
_locale:
{ _ordinalParse: /\d{1,2}(th|st|nd|rd)/,
ordinal: [Function],
_abbr: 'en',
_ordinalParseLenient: /\d{1,2}(th|st|nd|rd)|\d{1,2}/ },
_d: Fri Jun 01 2012 00:00:00 GMT-0300 (UYT),
_isValid: true } }
Conclusion
Proxies are a pretty exciting feature in ECMAScript 6. For one thing, we'll
finally be able to implement syntactic sugar on top of array accesses. Proxies
are currently pretty unstable
(MDN and NodeJS still
disagree on function signatures), but as they stabilize they'll enable some
very sweet JavaScript. Just like how Object.defineProperty()
made
mongoose possible, proxies will enable modules that work like magic.