Using ES6 Proxy to meta-program in Javascript (implement caching, logging, etc.)

Niwat
5 min readNov 15, 2017

--

Proxy has been introduced into Javascript with ES6. It’s a powerful new language feature that allows us to control another object in Javascript.

Proxies enable entirely new capabilities that weren’t possible before, like intercepting and defining custom behaviors for fundamental language operations (this is also referred as meta-programming). What does this means and how are they useful?

I’ve recently used Proxies in a web-app I’m building, and will show some simple (but hopefully useful) examples.

Let’s first start with how proxies work:

const stock = { symbol: 'aapl', price: 6.56}const stock_proxied = new Proxy(stock, {
// We will specify 'traps' here.
})

We create proxies by using the built-in Proxy constructor, and by passing an object as the first argument (functions or arrays also objects in JS, so they can be passed as well).

The second argument is an object containing traps, which are functions to be called when certain interactions occur on the passed object (the ‘stock’ object in this example).

Examples of traps:

  • Read a property of the object: get()
  • Set a property of the object set()
  • Call a function: apply()
  • Create an instance of the object : construct()

There are more traps available, but we won’t cover all of them here. You can check the Mozilla website for the complete list and browser support.

Let’s continue our example by adding a trap function:

const stock = { symbol: 'aapl', price: 6.56}const stock_logIt = new Proxy(stock, {
get: (target, key) => {
log('Reading ' + key)
return key in target ? target[key] : 'no property'
}
})

Every time something will read a property on our stock_logIt object, our log() function will log a message. We then return the property of our initial stock object, but here we made a change: if the property doesn’t exist, we return ‘no property’.

stock.symbol // ouput: "appl"
stock.name // output: "undefined"
stock_logIt.symbol // ouput: "appl". Logged "Reading appl"
stock_logIt.name // output: "no property"

We can see how proxies can be used to add logging on actions happening to an object, and also how to alter the return values when we read properties from an object.

The Schema Validation Proxy

With the following proxy, we ensure that we can only write numbers to the property price of our stock object.

const stock = { price: 12.55 }const stock_with_validator = new Proxy(stock, {
set (target, key, value) {
if (key === 'price') {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeError('price must be a number')
} else if (value <= 0) {
throw new TypeError('price must be a positive number')
} else {
target[key] = value
}
}
return true
}
})
stock_validator.price = '45.3' // TypeError: price must be a number
stock_validator.price = true // TypeError: price must be a number
stock_validator.price = 0 // TypeError: price must positive
stock_validator.price = 45.3
console.log(stock.price) // Output: 45.3

If we try to assign a string or a boolean to the property price of our stock object, or a number equal or lower than zero, the Javascript engine will throw an error.

Now that we understand the basics, let’s create a Caching Proxy:

Let say we need to call this API ($0.01 per call), that gives us the closing stock price for any given public company, on any past date. We need to call this function to frequently perform calculations, so it can get expensive in the end.

async function getStockPrice (symbol, date) {
let res = await fetch('http://s.api?sy=symbol&date=date)
return res.json()
}

Past stock prices are not going to change, so we can cache the results (and save money). We could add a caching mechanism inside the function (an create a self-memoizing function):

async function getStockPrice (symbol, date) {
let cache = {}
let argKey = symbol+date // create a unique identifier
if (cache.argKey) {
return cache.argKey
}
const res = await fetch('http://s.api?sy=symbol&date=date')
cache.argKey = res.json()
return cache.argKey
}

This works fine, but we shouldn’t alter the getStockPrice() function that should only do one thing and do it well, hence we should implement the caching logic somewhere else.
And this makes sense because we may want to use the same caching mechanism for our other functions as well:

  • Other API calls, like a getSearchResults(str) search function .
  • Functions doing heavy calculations, where there is a good chance of them being called with the same arguments set.
  • Recursive functions.

We can abstract the function caching mechanism using a Proxy:

function cacheFunctionResult (fn) {
let cache = {}
return new Proxy(fn, {
apply: async (target, thisArg, args) => {
cache[target.name] = cache[target.name] || {}
let argsKey = args.toString()
if (cache[target.name][argsKey]) {
return cache[target.name][argsKey]
} else {
cache[target.name][argsKey] = await target.apply(thisArg, args)
return cache[target.name][argsKey]
}
}
})
}

Our apply trap will be activated every time we call the passed-in ‘fn’ function, and will create a cache key ‘argsKey’ with the function arguments. It will then store the function result into our cache object.

If the function with the same set of arguments is called, we return the value directly from the cache, bypassing the function invocation.

We are using async/await in our trap because we are caching asynchronous functions, like getStockPrice(), and need to wait for the result.

Using our caching proxy:

const getStockPriceCached = cacheFunctionResult(getStockPrice)getStockPriceCached('AAPL', '2010-12-02') // 12.3 (Store into cache)
getStockPriceCached('AAPL', '2010-12-02') // 12.3 (Read from cache)
getStockPriceCached('AAPL', '2010-12-14') // 14.5 (Store into cache)
getStockPriceCached('GOOG', '2010-12-02') // 66.3 (Store into cache)

On the second function invocation, we are using the same arguments than in the first line, so we are reading it from cache. The 3rd and 4th invocations are called with a specific set of arguments, so getStockPrice() will be actually called.

We could go further and add cache invalidation. For example, after a certain number of calls from the cache, we could empty the cache. Any caching will increase memory usage, so we need to be careful.

How to write a simple test for our cacheFunctionResult() above? Test snippet at the end of this post.

Other use-cases of proxies examples

  • Calculate the execution time of a function.
  • A Negative array index. Here the developer emulates with a Proxy a useful Python feature, in JS. array[-1] will return the last element of an array, array[-2] the second last, etc.

Proxy Performance considerations

Proxies let us monitor our objects and define custom actions on specific interactions with them, and implement elegantly caching or logging.

There is one downside however: Proxies add additional processing time, which is far from negligible.

In our caching function, because we are dealing with async functions, this additional processing time is insignificant. But for other use-cases, such as logging, one should be careful when using them with objects that get interacted with a lot.

Read more

A test for our Caching Proxy

We can use a function returning a different result each time, such as number series generator, to test our caching Proxy.

function* number_generator () {
let i = 0
while (true) {
i++
yield i
}
}
const number_iterator = number_generator()
const get_new_number = () => number_iterator.next().value
async function runTest() {
let get_new_number_cached = cacheFunctionResult(get_new_number)
let get_new_number_cached_2 = cacheFunctionResult(get_new_number)
let strA1 = await get_new_number_cached(1, 2, 3) // 1
let strA2 = await get_new_number_cached(1, 2, 3) // 1
let strB1 = await get_new_number_cached('abc') // 2
let strC1 = await get_new_number_cached_2('abc') // 3
console.assert(strA1 === strA2, 'Caching works')
console.assert(strB1 !== strA2, 'Cache is specific to arguments')
console.assert(strB1 !== strC1, 'Cache is attached to each
function expression')
}

get_new_number() returns an incrementing number (1, 2, 3 …) every time it’s called. The first assert statement checks if our caching Proxy works by checking if successive function calls with same arguments are returning the same result.

--

--