JavaScript: How To Intercept Function and Method Calls

Intercepting function or method invocation in JavaScript. Monkey-patching or proxies. Let’s talk about that.

By MelkorNemesis

February 17th, 2021

image

The other day I had an object and needed to know when any of its methods were invoked. There were many methods on that object, and these methods were invoked throughout a lot of files. So it seemed impractical to add an extra line of code after every invocation to get a notification of the invocation.

The problem

Let’s consider the following object:

let myObj = {
  multiply(x, y) {
    return x * y;
  },
  squared(x) {
    return x ** x;
  }
};

Now every time you call myObj.multiply(x, y) or myObj.squared(x), you want to know that a method was invoked on the myObj object. And in the ideal world, you would like to know which method it was and what arguments it was invoked with. Something like this.

interceptMethodCalls(myObj, (fnName, fnArgs) => { ... });

Monkey-patching

“Monkey patching is a technique to add, modify, or suppress the default behavior of a piece of code at runtime without changing its original source code.” https://www.audero.it/blog/2016/12/05/monkey-patching-javascript

So monkey-patching is basically a dynamic replacement of attributes at runtime. How can we go about our problem with monkey-patching?

function interceptMethodCalls(obj, fn) {
  Object.keys(obj).forEach(key => { // (A)
    const prop = obj[key];
    if (typeof prop === 'function') { // (B)
      const origProp = prop;
      obj[key] = (...args) => { // (C)
        fn(key, args); // (D)
        return Reflect.apply(origProp, obj, args); // (E)
      }
    }
  });
}

On line (A), we go through the given object’s property names. On line (B) we check if the object’s property is a function. The magic happens on line (C), where we replace the original function with an anonymous function — this is the monkey-patching piece. That anonymous function calls the given fn function (D) with two arguments: 1. the name of the invoked function, 2. the arguments the function was invoked with (as an array). And finally, we’re invoking the original function, using Reflect API.

This works great 👍.

const handleMethodCall = (fnName, fnArgs) =>
  console.log(`${fnName} called with `, fnArgs);

interceptMethodCalls(myObj, handleMethodCall);

myObj.multiply(2, 7); // "multiply called with [ 2, 7 ]"
myObj.squared(2); // "squared called with [ 2 ]"

But. There’s always a but️.

What the monkey-patching does is that it changes the object in place. In our case the myObj. After calling interceptMethodCalls(myObj, handleMethodCall);, all of the object methods are replaced with different ones — line (C).

And we don’t want that. There’s a better solution.

Proxy

“The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.”
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy

A JavaScript Proxy wraps another object (the target) and allows you to intercept fundamental operations on the target object. Operations like getting a value, setting a value, calling a function. These operations are called traps. This sounds exactly like what we’re trying to achieve here.

apply trap

“The handler.apply() method is a trap for a function call.” ~ https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/apply

The apply trap could seem like a good fit. There’s a catch, though. Calling a method on an object is actually two operations.

  1. Retrieving the function - get trap

  2. Calling the function - apply trap

There’s no **trap **for a method call, so we will combine the get and apply traps.

The working solution is as follows:

function interceptMethodCalls(obj, fn) {
  return new Proxy(obj, {
    get(target, prop) { // (A)
      if (typeof target[prop] === 'function') {
        return new Proxy(target[prop], {
          apply: (target, thisArg, argumentsList) => { // (B)
            fn(prop, argumentsList);
            return Reflect.apply(target, thisArg, argumentsList);
          }
        });
      } else {
        return Reflect.get(target, prop);
      }
    }
  });
}

It’s a little longer than the monkey-patching solution, but it’s cleaner. We’re not, at any point, modifying the original object or its methods. We’re just wrapping them in a Proxy.

On line (A) we set the get trap on the obj parameter. So when we access some method (e.g. myObj.multiply from the example before) this trap is triggered. It’s just a property on the object. And the property’s value is simply a function.

JavaScript Proxies When Intercepting Method CallsJavaScript Proxies When Intercepting Method Calls

Now we’re ready to create a proxied version of the retrieved function, which we can access through target[prop]. So we make a new Proxy, set up the apply trap, and return that Proxy instead of the original method.

So it’s basically a two-step process, but it’s worth it because you change neither the object nor its methods.

Playground

You can have a play with the code above at https://repl.it/@MelkorNemesis/Intercept-Method-Calls.



Continue Learning