DEV Community

Zach Plata
Zach Plata

Posted on

Guarding JS Objects with Proxy

A guard's job is to protect something or someone and intercept potential interactions with the thing they guard. Here, we'll talk about a guard whose job is to protect a JavaScript object! And using this guard is free! Well, kind of.. let's get into it.

What is a Proxy

A JavaScript Proxy is an object that wraps another object, and defines how to handle any operation on that wrapped object (i.e. function calls, get/set properties, and more). We'll get into some examples that make that a little clearer to understand.

const myProxy = new Proxy(target, handler);
Enter fullscreen mode Exit fullscreen mode

The idea is that when you create a Proxy, you provide two arguments, both objects:

  • target - the object the proxy guards
  • handler - an object that intercepts and potentially redefines operations on the target. You may see these redefined operations also referred to as "traps" in docs

Here's a small example of what this looks like:

const foo = {
  a: 1,
  b: 2,
};

const bar = new Proxy(foo, {
  // Intercept setter operation
  set(target, property, value) {
    if (target[property] === undefined) {
      console.error(
        `Property ${property} does not exist! Check the object again.`,
      );
      return false;
    }
    // If the property exists, allow the setter operation to go through
    target[property] = value;
    return true;
  },
});

// This should console error! Property 'c' does not exist on Object 'foo'
bar.c = 3;
Enter fullscreen mode Exit fullscreen mode

In the above snippet, we create a Proxy bar that wraps or "guards" the object foo by intercepting the "set property" operation on foo. In this interception, we add a check to see if the property being set on foo exists, and if not, preventing that property from being set on foo, and adding a console.error() appropriately. We also return true at the end to indicate success if we can set the property.

This probably brings up some natural questions. Why not just handle property access checks on the object in my app logic? Why do I need a middle-man gate-keeping my JS object? Well, there are a number of cases you might use this handy fella!

Use Cases

Setting a proxy on itself

Yes, it sounds weird. Why would an object need to be its own guard? Well, let's take a simple example of a "user" object with a number of API functions attached for POSTing data.

let user = {
  writePost: (data) => {...},
  deletePost: (data) => {...},
  likePost: (data) => {...},
  commentPost: (data) => {...},
  // ... dozens more API actions
};
Enter fullscreen mode Exit fullscreen mode

Imagine that you call the API functions on the user object dozens or maybe a hundred times throughout the application. And now, some new business requirements come in where you need to write some logic that checks if a user's session ID is stored in localStorage before calling into these APIs, and if not, route the user to a login page.

You could go about adding checks in each of the user API functions.. but that could be tedious and boring. Enter the proxy! By wrapping user in a proxy that targets itself, you can inject some logic before any API function calls by adding logic in the "getter" operation in the handler.

let user = {
    writePost: (data) => {...},
    deletePost: (data) => {...},
    likePost: (data) => {...},
    commentPost: (data) => {...},
    // ... dozens more API actions
};
user = new Proxy(user, {
    get(target, property) {
        if (typeof target[property] === "function") {
            if (isUserInSession(target)) {
                return function (...args) {
                    return target[property].apply(target, args);
                };
            } else {
                // route to login page
            }
        }
        return target[property];
    }
});
Enter fullscreen mode Exit fullscreen mode

In the scenario above, whenever an API function is called, we make a check to a utility function to see if the user is in session, and if so, can proceed with calling the API function, or routing the user to a login.

Putting aside that this solution may be suited better as some kind of middleware or check elsewhere, the proxy-on-itself method allows for a nice place to interject some kind of check or logic before allowing some API on the object to be invoked.

Easier and cleaner debugging

We've all spent time doing some variation of "step-by-step" debugging with a good ol' series of console.log()'s in our code. I still do this, no shame in the game.

obj.increment();
console.log("WHATS", obj);
obj.decrement();
console.log("GOING", obj);
obj.increment(); // Bug appears here
console.log("ON!!!", obj);
Enter fullscreen mode Exit fullscreen mode

Of course, you can use an actual debugger tool to step through code, but I'll throw in that using a Proxy to add debugging is an acceptable and efficient way, too.

obj = new Proxy(obj, {
  get(target, property) {
    if (typeof target[property] === "function") {
      console.log("State of obj: ", obj);
      console.log(`Invoking ${property}()`);
      return function (...args) {
        return target[property].apply(target, args);
      };
    }
    return target[property];
  }
})
Enter fullscreen mode Exit fullscreen mode

Now you don't have to have a string of unique console log messages between API calls. You can just wrap your object in a proxy and have it do the console logging for you. And, you can extend this to a more robust debugging model for adding checks before function calls, getting property values, setting property values, and more. In this case, the Proxy acts as more of an assistant to the object than a guard 🤷

Bridge between two objects

Taking a look at a more real-world use case, a need came up in some recent work at Rive on the JS/WASM runtime. In this library, there are two objects of concern:

  • A Canvas2D context for drawing operations to draw onto a <canvas> via canvas.getContext('2d');
  • A custom renderer object (CanvasRenderer) with APIs similar to that of the Canvas2D context (i.e. transform(), save(), restore(), etc.) alongside some extra custom APIs for drawing Rive content. This renderer object uses the Canvas2D context to draw to the canvas.

Normally, users of this library will control all drawing and canvas-related operations through the CanvasRenderer, and that object will use the Canvas2D context behind the scenes to carry out the operations. However, the CanvasRenderer only wraps/implements a subset of APIs that exist on the Canvas2D context. Naturally, some users wanted access to the Canvas2D context for one reason or another.

This proved challenging as some of the core drawing APIs needed to go through CanvasRenderer rather than the context directly, so how could the library provide the best of both worlds?

Spoiler alert: Proxy!

The solution was exposing a Proxy for users to manipulate as the "canvas context." If the Proxy detected that a function call being requested by the caller existed on the CanvasRenderer, it invoked the function from there accordingly. If the Proxy detected that a function call being made did not exist on the CanvasRenderer, it must have been a call intended for a function on the underlying Canvas2D context, and thus the Proxy invokes the function call on the context instead.

Here's a snippet of what this looked like:

const newCanvasRenderer = new CanvasRenderer(canvas);
const c2dSource = newCanvasRenderer._ctx; // C2D Context
return new Proxy(newCanvasRenderer, {
  get(target, property) {
    if (typeof target[property] === "function") {
      return function (...args) {
        return target[property].apply(target, args);
      };
    } else if (typeof c2dSource[property] === "function") {
      // Some functions on C2D are blocked, and adding this 
      // logic to a proxy helped centralize a place for that
      if (c2dMethodBlockList.indexOf(property) > -1) {
        throw new Error(
          "RiveException: Method call to '" +
            property +
           "()' is not allowed, as the renderer cannot immediately pass through the return \
            values of any canvas 2d context methods."
        );
      } else {
        // Use the C2D context as needed
        return function (...args) {
          newCanvasRenderer._drawList.push(
            c2dSource[property].bind(c2dSource, ...args)
          );
        };
      }
    }
    return target[property];
  },
  // There are no properties to set on CanvasRenderer, so all
  // property setting can go through the C2D context
  set(target, property, value) {
    if (property in c2dSource) {
      c2dSource[property] = value;
      return true;
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

This solution served as a win from the library side in being able to utilize Rive-specific rendering APIs, and also a win for users to use common Canvas2D APIs for other actions that Rive didn't implement; a Proxy as a bridge between two similar objects!

There's a ton more clear and well-defined documentation on the Proxy that you should check out on further usage and caveats, but hopefully you can start to see some of the benefits and use cases for this unsung hero, a guard for JavaScript objects.

Top comments (1)

Collapse
 
cassidoo profile image
Cassidy Williams

This was super cool, thanks for sharing, Zach!