JavaScript Proxy Objects

By

A practical guide to JavaScript Proxy and Reflect: traps, validation, defaults, method receivers, invariants, revocable proxies, and common pitfalls.

~~~

When working with objects, we can create a proxy object that intercepts and changes the behavior of an existing object.

We do so using the Proxy native object, introduced in ES2015.

Suppose we have a car object:

const car = {
  color: 'blue'
}

A very simple example we can make is to return a ‘Not found’ string when we try to access a property that does not exist.

You can define a proxy that is called whenever you try to access a property of this object.

You do so by creating another object that has a get() method, which receives the target object and the property as parameters:

const car = {
  color: 'blue'
}

const handler = {
  get(target, property) {
    return Reflect.get(target, property) ?? 'Not found'
  }
}

Now we can initialize our proxy object by calling new Proxy(), passing the original object, and our handler:

const proxyObject = new Proxy(car, handler)

Now try accessing a property contained in the car object, but referencing it from proxyObject:

proxyObject.color //'blue'

This is just like calling car.color.

But when you try to access a property that does not exist on car, like car.test, you’d get back undefined. Using the proxy, you will get back the 'Not found' string, since that’s what we told it to do.

proxyObject.test //'Not found'

We’re not limited to the get() method in a proxy handler. That was just the simplest example we could write.

We have other methods we can use:

and so on. Basically we can create a guarded gate that controls everything that happens on an object, and provide additional rules and controls to implement our own logic.

Other methods (also called traps) we can use are:

all corresponding to the respective functionality.

You can read more about each of those on MDN.

Let’s make another example using deleteProperty. We want to prevent deleting properties of an object:

const car = {
  color: 'blue'
}

const handler = {
  deleteProperty(target, property) {
    return false
  }
}

const proxyObject = new Proxy(car, handler)

If we call delete proxyObject.color, we’ll get a TypeError:

TypeError: 'deleteProperty' on proxy: trap returned falsish for property 'color'

Of course one could always delete the property directly on the car object, but if you write your logic so that that object is inaccessible and you only expose the proxy, that is a way to encapsulate your logic.

Start with Reflect

A proxy trap often wants to keep the normal behavior and add one small rule. The Reflect methods mirror the object’s internal operations, so they are the safest way to forward an operation to the target.

const handler = {
  get(target, property, receiver) {
    console.log(`Reading ${String(property)}`)
    return Reflect.get(target, property, receiver)
  },
}

We could write target[property], but Reflect.get() also receives the original receiver. That difference matters when getters or inheritance are involved.

The same pattern works for writes:

const handler = {
  set(target, property, value, receiver) {
    console.log(`Writing ${String(property)}`)
    return Reflect.set(target, property, value, receiver)
  },
}

A set trap must return a boolean. Returning false means the assignment failed and can produce a TypeError in strict mode.

Validate assignments

A proxy can protect an object at the moment a property changes:

const person = {
  name: 'Flavio',
  age: 45,
}

const validatedPerson = new Proxy(person, {
  set(target, property, value, receiver) {
    if (property === 'age' && (!Number.isInteger(value) || value < 0)) {
      throw new TypeError('Age must be a positive integer')
    }

    return Reflect.set(target, property, value, receiver)
  },
})

validatedPerson.age = 46
validatedPerson.age = -1 //TypeError

This can be convenient at a boundary. It can also hide important behavior behind ordinary assignment syntax. For domain objects, an explicit method such as person.changeAge() is often easier to discover and test.

Provide computed properties

The get trap can expose values that do not exist on the target:

const user = {
  firstName: 'Ada',
  lastName: 'Lovelace',
}

const profile = new Proxy(user, {
  get(target, property, receiver) {
    if (property === 'fullName') {
      return `${target.firstName} ${target.lastName}`
    }

    return Reflect.get(target, property, receiver)
  },
})

profile.fullName //'Ada Lovelace'

Be consistent. If fullName appears to be a real property, code may also expect 'fullName' in profile, Object.keys(profile), and property descriptors to agree. Supporting that illusion requires more traps and creates more complexity.

Intercept functions and constructors

The target must be callable before the apply trap can run:

function add(a, b) {
  return a + b
}

const tracedAdd = new Proxy(add, {
  apply(target, thisValue, argumentsList) {
    console.log(argumentsList)
    return Reflect.apply(target, thisValue, argumentsList)
  },
})

tracedAdd(2, 3) //5

Similarly, construct only works when the target can be called with new:

class User {
  constructor(name) {
    this.name = name
  }
}

const TracedUser = new Proxy(User, {
  construct(target, argumentsList, newTarget) {
    console.log(`Creating ${argumentsList[0]}`)
    return Reflect.construct(target, argumentsList, newTarget)
  },
})

Understand proxy invariants