ponyfoo.com

More ES6 Proxy Traps in Depth

Fix
A relevant ad will be displayed here soon. These ads help pay for my hosting.
Please consider disabling your ad blocker on Pony Foo. These ads help pay for my hosting.
You can support Pony Foo directly through Patreon or via PayPal.

Hey there! This is ES6 – “Traps? Again?” – in Depth. Looking for other ES6 goodness? Refer to A Brief History of ES6 Tooling. Then, make your way through destructuring, template literals, arrow functions, the spread operator and rest parameters, improvements coming to object literals, the new classes sugar on top of prototypes, let, const, and the “Temporal Dead Zone”, iterators, generators, Symbols, Maps, WeakMaps, Sets, and WeakSets, proxies, and proxy traps. We’ll be discussing about more ES6 proxy traps today.

Like I did in previous articles on the series, I would love to point out that you should probably set up Babel and follow along the examples with either a REPL or the babel-node CLI and a file. That’ll make it so much easier for you to internalize the concepts discussed in the series. If you aren’t the “install things on my computer” kind of human, you might prefer to hop on CodePen and then click on the gear icon for JavaScript – they have a Babel preprocessor which makes trying out ES6 a breeze. Another alternative that’s also quite useful is to use Babel’s online REPL – it’ll show you compiled ES5 code to the right of your ES6 code for quick comparison.

Note that Proxy is harder to play around with as Babel doesn’t support it unless the underlying browser has support for it. You can check out the ES6 compatibility table for supporting browsers. At the time of this writing, you can use Microsoft Edge or Mozilla Firefox to try out Proxy. Personally, I’ll be verifying my examples using Firefox.

Before getting into it, let me shamelessly ask for your support if you’re enjoying my ES6 in Depth series. Your contributions will go towards helping me keep up with the schedule, server bills, keeping me fed, and maintaining Pony Foo as a veritable source of JavaScript goodies.

Thanks for reading that, and let’s go into even more Proxy traps now! If you haven’t yet, I encourage you to read the previous article on the Proxy built-in for an introduction to the subject and the one about the first few traps I covered.

But Wait! There’s More… (Proxy Trap Handlers)

This article covers all the trap handlers that weren’t covered by the two previous articles on proxies. For the most part, the traps that we discussed yesterday had to do with property manipulation, while the first five traps we’ll dig into today have mostly to do with the object being proxied itself. The last two have to do with properties once again – but they’re a bit more involved than yesterday’s traps, which were much easier to “fall into” (the trap – muahaha) in your everyday code.

construct

You can use the handler.construct method to trap usage of the new operator. Here’s a quick “default implementation” that doesn’t alter the behavior of new at all. Remember our friend the spread operator?

var handler = {
  construct (target, args) {
    return new target(...args)
  }
}

If you use the handler options above, the new behavior you’re already used to would remain unchanged. That’s great because it means whatever you’re trying to accomplish you can still fall back to the default behavior – and that’s always important.

function target (a, b, c) {
  this.a = a
  this.b = b
  this.c = c
}
var proxy = new Proxy(target, handler)
console.log(new proxy(1,2,3))
// <- { a: 1, b: 2, c: 3 }

Obvious use cases for construct traps include data massaging in the arguments, doing things that should always be done around a call to new proxy(), logging and tracking object creation, and swapping implementations entirely. Imagine a proxy like the following in situations where you have inheritance “branching”.

class Automobile {}
class Car extends Automobile {}
class SurveillanceVan extends Automobile {}
class SUV extends Automobile {}
class SportsCar extends Car {}
function target () {}
var handler = {
  construct (target, args) {
    var [status] = args
    if (status === 'nsa') {
      return new SurveillanceVan(...args)
    }
    if (status === 'single') {
      return new SportsCar(...args)
    }
    return new SUV(...args) // family
  }
}

Naturally, you could’ve used a regular method for the branching part, but using the new operator also makes sense in these types of situations, as you’ll end up creating a new object in all code branches anyways.

console.log(new proxy('nsa').constructor.name)
// <- `SurveillanceVan`

The most common use case for construct traps yet may be something simpler, and that’s extending the target object right after creation, – and before anything else happens – in such a way that it better supports the proxy gatekeeper. You might have to add a proxied flag to the target object, or something akin to that.

getPrototypeOf

You can use the handler.getPrototypeOf method as a trap for all of the following.

You could use this trap to make an object pretend it’s an Array, when accessed through the proxy. However, note that that on its own isn’t sufficient for the proxy to be an actual Array.

var handler = {
  getPrototypeOf: target => Array.prototype
}
var target = {}
var proxy = new Proxy(target, handler)
console.log(proxy instanceof Array)
// <- true
console.log(proxy.push)
// <- undefined

Naturally, you could keep on patching your proxy until you get the behavior you want. In this case, you may want to use a get trap to mix the Array.prototype with the actual back-end target. Whenever a property isn’t found on the target, we’ll use reflection to look it up on Array.prototype. It turns out, this is good enough for most operations.

var handler = {
  getPrototypeOf: target => Array.prototype,
  get (target, key) {
    return Reflect.get(target, key) || Reflect.get(Array.prototype, key)
  }
}
var target = {}
var proxy = new Proxy(target, handler)
console.log(proxy.push)
// <- function push () { [native code] }
proxy.push('a', 'b')
console.log(proxy)
// <- { 0: 'a', 1: 'b', length: 2 }

I definitely see some advanced use cases for getPrototypeOf traps in the future, but it’s too early to tell what patterns may come out of it.

setPrototypeOf

The Object.setPrototypeOf method does exactly what its name conveys: it sets the prototype of an object to a reference to another object. It’s considered the proper way of setting the prototype as opposed to using __proto__ which is a legacy feature – and now standarized as such.

You can use the handler.setPrototypeOf method to set up a trap for Object.setPrototypeOf. The snippet of code shown below doesn’t alter the default behavior of changing a prototype to the value of proto.

var handler = {
  setPrototypeOf (target, proto) {
    Object.setPrototypeOf(target, proto)
  }
}
var proto = {}
var target = function () {}
var proxy = new Proxy(target, handler)
proxy.setPrototypeOf(proxy, proto)
console.log(proxy.prototype === proto)
// <- true

The field for setPrototypeOf is pretty open. You could simply not call Object.setPrototypeOf and the trap would sink the call into a no-op. You could throw an exception making the failure more explicit – for instance if you deem the new prototype to be invalid or you don’t want consumers pulling the rug from under your feet.

This is a nice trap to have if you want proxies to have limited access to what they can do with your target object. I would definitely implement a trap like the one below if I had any security concerns at all in a proxy I’m passing away to third party code.

var handler = {
  setPrototypeOf (target, proto) {
    throw new Error('Changing the prototype is forbidden')
  }
}
var proto = {}
var target = function () {}
var proxy = new Proxy(target, handler)
proxy.setPrototypeOf(proxy, proto)
// <- Error: Changing the prototype is forbidden

Then again, you may want to fail silently with no error being thrown at all if you’d rather confuse the consumer – and that may just make them go away.

isExtensible

The handler.isExtensible method can be mostly used for logging or auditing calls to Object.isExtensible. This trap is subject to a harsh invariant that puts a hard limit to what you can do with it.

If Object.isExtensible(proxy) !== Object.isExtensible(target), then a TypeError is thrown.

You could use the handler.isExtensible trap to throw if you don’t want consumers to know whether the original object is extensible or not, but there seem to be limited situations that would warrant such an incarnation of evil. For completeness’ sake, the piece of code below shows a trap for isExtensible that throws errors every once in a while, but otherwise behaves as expected.

var handler = {
  isExtensible (target) {
    if (Math.random() > 0.1) {
      throw new Error('gotta love sporadic obscure errors!')
    }
    return Object.isExtensible(target)
  }
}
var target = {}
var proxy = new Proxy(target, handler)
console.log(Object.isExtensible(proxy))
// <- true
console.log(Object.isExtensible(proxy))
// <- true
console.log(Object.isExtensible(proxy))
// <- true
console.log(Object.isExtensible(proxy))
// <- Error: gotta love sporadic obscure errors!

While this trap is nearly useless other than for auditing purposes and to cover all your bases, the hard-to-break invariant makes sense because there’s also the preventExtensions trap. That one is a little bit more useful!

preventExtensions

You can use handler.preventExtensions to trap the Object.preventExtensions method. When extensions are prevented on an object, new properties can’t be added any longer – it can’t be extended.

Imagine a scenario where you want to selectively be able to preventExtensions on some objects – but not all of them. In this scenario, you could use a WeakSet to keep track of the objects that should be extensible. If an object is in the set, then the preventExtensions trap should be able to capture those requests and discard them. The snippet below does exactly that. Note that the trap always returns the opposite of Object.isExtensible(target), because it should report whether the object has been made non-extensible.

var mustExtend = new WeakSet()
var handler = {
  preventExtensions (target) {
    if (!mustExtend.has(target)) {
      Object.preventExtensions(target)
    }
    return !Object.isExtensible(target)
  }
}

Now that we’ve set up the handler and our WeakSet, we can create a back-end object, a proxy, and add the back-end to our set. Then, you can try Object.preventExtensions on the proxy and you’ll notice it fails to prevent extensions to the object. This is the intended behavior as the target can be found in the mustExtend set.

var target = {}
var proxy = new Proxy(target, handler)
mustExtend.add(target)
Object.preventExtensions(proxy)
// <- TypeError: proxy preventExtensions handler returned false

If we removed the target from the mustExtend set before calling Object.preventExtensions, then target would be made non-extensible as originally intended.

var target = {}
var proxy = new Proxy(target, handler)
mustExtend.add(target)
mustExtend.delete(target)
Object.preventExtensions(proxy)
console.log(Object.isExtensible(proxy))
// <- false

Naturally, you could use this distinction to prevent proxy consumers from making the proxy non-extensible in cases where that could lead to undesired behavior. In most cases, you probably won’t have to deal with this trap, though. That’s because you’re usually going to be working with the back-end target for the most part, and not so much with the proxy object itself.

getOwnPropertyDescriptor

You can use the handler.getOwnPropertyDescriptor method as a trap for Object.getOwnPropertyDescriptor. It may return a property descriptor, such as the result from Object.getOwnPropertyDescriptor(target, key); or undefined, signaling that the property doesn’t exist. As usual, you also have the third option of throwing an exception, aborting the operation entirely.

If we go back to our canonical “private property space” example, we could implement a trap such as the one seen below to prevent consumers from learning about property descriptors of private properties.

var handler = {
  getOwnPropertyDescriptor (target, key) {
    invariant(key, 'get property descriptor for')
    return Object.getOwnPropertyDescriptor(target, key)
  }
}
function invariant (key, action) {
  if (key[0] === '_') {
    throw new Error(`Invalid attempt to ${action} private "${key}" property`)
  }
}
var target = {}
var proxy = new Proxy(target, handler)
Object.getOwnPropertyDescriptor(proxy, '_foo')
// <- Error: Invalid attempt to get property descriptor for private "_foo" property

The problem with that approach is that you’re effectively telling consumers that properties with the _ prefix are somehow off-limits. It might be best to conceal them entirely by returning undefined. This way, your private properties will behave no differently than properties that are actually absent from the target object.

var handler = {
  getOwnPropertyDescriptor (target, key) {
    if (key[0] === '_') {
      return
    }
    return Object.getOwnPropertyDescriptor(target, key)
  }
}
var target = { _foo: 'bar', baz: 'tar' }
var proxy = new Proxy(target, handler)
console.log(Object.getOwnPropertyDescriptor(proxy, 'wat'))
// <- undefined
console.log(Object.getOwnPropertyDescriptor(proxy, '_foo'))
// <- undefined
console.log(Object.getOwnPropertyDescriptor(proxy, 'baz'))
// <- { value: 'tar', writable: true, enumerable: true, configurable: true }

Usually when you’re trying to hide things it’s best to have them try and behave as if they fell in some other category than the category they’re actually in. Throwing, however, just sends the “there’s something sketchy here, but we can’t quite tell you what that is…” message – and the consumer will eventually find out why that is.

Keep in mind that if debugging concerns outweight security concerns, you probably should go for the throw statement.

Conclusions

This has certainly been fun. I now have a much better understanding of what proxies can do for me, and I think they’ll be an instant hit once ES6 starts gaining more traction. I for one can’t be more excited about them becoming well-supported in more browsers soon. I wouldn’t hold out my hopes about proxies for Babel, as many of the traps are ridiculously hard (or downright impossible) to implement in ES5.

As we’ve learned over the last few days, there’s a myriad use cases for proxies. Off the top of my head, we can use Proxy for all of the following.

  • Add validation rules – and enforce them – on plain old JavaScript objects
  • Keep track of every interaction that goes through a proxy
  • Decorate objects without changing them at all
  • Make certain properties on an object completely invisible to the consumer of a proxy
  • Revoke access at will when the consumer should no longer be able to use a proxy
  • Modify the arguments passed to a proxied method
  • Modify the result produced by a proxied method
  • Prevent deletion of specific properties through the proxy
  • Prevent new definitions from succeeding, according to the desired property descriptor
  • Shuffle arguments around in a constructor
  • Return a result other than the object being new-ed up in a constructor
  • Swap out the prototype of an object for something else

I can say without a shadow of a doubt that there’s hundreds more of use cases for proxies. I’m sure many libraries will adopt a pattern we’ve discussed here in the series where a “back-end” target object is created and used for storage purposes but the consumer is only provided with a “front-end” proxy object with limited and audited interaction with the back-end.

What would you use Proxy for?

Meet me tomorrow at… say – same time? We can talk about Reflect then.

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments