The Enigma of Javascript Proxies

And a recipe for a bespoke testing framework

Sam
10 min readApr 28, 2022
graphic design is my passion

This is quite a technically involved article. It is not for the faint of heart.

When proxies were introduced to Javascript via the ECMAScript 6 specification, I was giddy with excitement. “What great doors this will open”, I thought. “Metaprogramming in the best language in the world is finally here, and I can’t wait to use it responsibly!”

Narrator: He did not, as it turns out, use this responsibly.

Poring over specifications that I convinced myself I was able to understand, I realized that t̶h̶e̶r̶e̶ ̶w̶a̶s̶ ̶n̶o̶ ̶m̶e̶a̶n̶i̶n̶g̶ ̶t̶o̶ ̶l̶i̶f̶e proxies could be quite powerful if used correctly. Sure, you could do the boring things like “log property accesses, validate, format, or sanitize inputs, and so on”, but… what if you didn’t want to do those things? What if you wanted more?

As it turns out, attaining omnipotence within Javascript is entirely possible. It simply involves using a wonderfully magnificently elegant feature and gleefully defiling it such a manner its creators would be ashamed of.

T̶h̶e̶s̶e̶ ̶a̶r̶e̶ ̶t̶h̶e̶i̶r̶ ̶s̶t̶o̶r̶i̶e̶s̶. This is my story.

An Exotic Prelude

The ECMAScript 2023 specification has the following definition:

A Proxy object is an exotic object whose essential internal methods are partially implemented using ECMAScript code.

An exotic object, quite helpfully, is simply anything that is not an ordinary object. Read that definition at your own risk.

Most important here are the “essential internal methods”; in this case, a special internal method called [[Get]], which we can “override” by providing our own get method on what is known as a “Proxy Handler”. You can create a proxy object by simply writing new Proxy(target, handler) . The target is then known as the proxy’s target object. The result of this mess is a facade for your target object, with the proxy handler lying in-between the facade and the target.

A Delicious Layering

We now have three delectable toppings on our tempting proverbial sandwich:

  • Proxy Object: The exotic object, and what other code actually “sees”
  • Proxy Target Object: An object “wrapped” by the proxy object.
  • Proxy Handler: An object containing certain methods “overriding” those on the proxy object, to administrate property accesses and other fun things

With a diagram, let’s picture some sandwich object that has been wrapped with a proxy:

how delicious

All the external code is able to see is the sandwich proxy object, and it has no knowledge of any of the internals. In requesting the type property on the sandwich object, the proxy object calls the handler with the get method, passing through a reference to the target object.

Notably, the handler is free to do whatever it wishes with this request. It can log something to the console, make an HTTP request, buy some bitcoin (before promptly losing it all) — it’s just a function, it has free will, limited only by the imagination of its creator.

Controversially, the notion of free will has been debated for centuries. One might think a discussion about moral culpability, determinism, libertarianism, and related topics have no place in a post about programming. One might be right.

The Bespoke Testing Framework

At Hack the North, we use an Apollo GraphQL backend to make writing server code simple. The concepts of GraphQL itself are not too important, but I’ll define required concepts as we go on to bake ourselves a yummy testing framework.

A Missing Dessert

A problem that we regularly run into on the backend side of code is both a lack of tests and a lack of motivation to write those tests. My thoughts on this are three-fold:

  • Overhead: GraphQL requires a request body even for the simplest of requests. This request body is typically untyped, even if using Typescript, as the definition is contained within a string. There is a library that explains this problem nicely and provides a solution much better than mine.
  • Abstraction: It’s difficult to remember that, despite all the overhead needed to construct a full GQL request, it all simply amounts to a function call on the server side.
  • Type-safety: Returned objects from the untyped requests are also untyped. This messes with code autocompletion — a feature I cannot live without.

A fourth reason, of course, is the fact that we’ll get to it next sprint. I promise.

To give some concrete examples, a simple test might look as follows:

test for updating a user’s email

This test isn’t too particularly complicated, but as the tests become increasingly complex, or as we want to test user flows that require multiple GQL queries, the actual “meat” of the test will get buried beneath all the overhead. It’s also quite ugly.

What We’re Making

One night I found myself under the weather and with a fever running quite high. Said fever had knocked me out for the day, but it gave me something better: a fever dream. That dream set the stage for today’s treat.

Instead of soaring through the skies or frolicking through some daisy fields, I had a dream about this problem. In particular, it was of me writing a test and — God forbid — actually enjoying it. I realized that a great solution to this conundrum would be to simply call the GQL resolver as if it was a function, abstracting all the HTTP overhead away from the developer.

Whether or not this was a good idea is beyond the scope of the article. At this point in time I was too fixated on what was possible rather than what was practical. This was personal now.

On a friendly note to the happy code-monkeys following along, this is what a resolver method might look like:

the Apollo framework handles dispatching requests to the appropriate resolver

What if we could simply call this as UserResolver.updateUserEmail(...) in our tests? What a joy that would be. Alas, t’was doomed to be merely a pipe dream.

Narrator: Or was it?

The Ingredients

Any good recipe calls for ingredients in imperial measurements that are impossible to convert cleanly to metric:

  • 3 ounces of codegen
  • 1.5 pounds of proxy
  • Half a tablespoon of determination
  • A sprinkle of insanity

With ingredients in hand we can move on to the next step.

Preparation

The separation between our testing environment and running backend server means we can’t actually directly call the resolver methods straight from our testing code.

this hard separation means we need some more magic to make it work

So instead, we create some genius code generation to create “stubbed” versions of the server-side resolvers that provide all the type information that IntelliSense (or similar) could possibly need. These stubs would also strip out all the decorators and any other unnecessary data.

the stub for the UpdateUserEmail method

Key items of note:

  • The type of query is no longer declared via decorator but simply returned inline. This is because I couldn’t find a way to get the presence of the @Mutation decorator at runtime.
  • The method has an explicit Promise<User> return type and completely disregards it by returning a string. We tell a lie to make code autocompletion work.
  • The method is marked static —Apollo internally instantiates new instances of each resolver, but for our testing code we would like not to have to new the resolver before calling methods upon it.

Run this codegen against every single resolver at compile time and we will have access to all the type information we could possibly need.

I’m sure there’s an even more convoluted way to obtain the type information from the original resolvers directly without codegen. My fever dream did not provide a solution.

an updated diagram

Time for some fun.

Cooking

Before continuing, make sure to preheat the oven to 400°F to save time!

Finally, we can wrap back to the proxy objects.

“Why do we need them?”, you, dear reader, might ask.

“So we can create a GQL request, of course!”

Recall that a proxy is able to intercept calls to the proxy target and do… anything it wants. Conveniently, a subset of “anything” includes constructing and sending a GQL request.

In order to protect our users from the horrors of untyped GQL requests, we must first lie to them a little. Instead of exporting the stub itself, we export a proxied stub, invisible to our developers. This forces clients to use the proxied stub rather than the stub itself, allowing us to correct their oft-misguided ways.

There is one small caveat: GQL requires that the client specify the fields that are desired on the response type. We cannot (easily) dynamically determine these fields, so in the actual implementation we kindly ask our test writers to define the fields they want. For this article, I will pretend that the server just knows what fields to return.

Proxy Handler

The proxy handler for the stub will intercept all function calls to the stub and construct a real GQL request to be delivered to the server.

The Apollo framework supports a feature called executeOperation that allows us to skip actually constructing the HTTP request ourselves. Since we’re not trying to test the HTTP layer, this will do just fine.

Conceptually, this proxy handler will process all the required information to construct a valid GQL request that is then dispatched to the Apollo server.

To do this, we not only need to intercept the call to updateUserEmail, but to every single method on the stub. Conveniently, the get trap allows us to do just that.

an implementation of the get trap method

Let’s take a look at this code as if UserResolver.updateUserEmail(1, “hello@email.com")were called:

  • target is the UserResolver proxy target, which is also the type T
  • prop is "updateUserEmail"
  • we return an async function to accept the (1, "hello@email.com") invocation
  • mapArgumentsToObject constructs a map of the parameters of the method calls to their arguments. For example, the method updateUserEmail is defined as updateUserEmail(id, email) , and since methods can be converted to strings, we can extract the parameter names, [“id", “email"]. We map the arguments the caller has provided to these parameter names, so a call to updateUserEmail(1, "hello@email.com") would create a neat object { id: 1, email: "hello@email.com" } .
  • generateQueryFor calls the stub method to determine the type of query. In this case, the stub returns "Mutation". Here we hand-wave some magic and say that it can also enumerate all the fields we want on the response.
  • delegate merges the query with the executionContext in order to map the arguments to their respective parameters for the actual GQL request. It then dispatches the request to the Apollo server via executeOperation.

The resulting query might appear as follows:

mutation {
updateUserEmail(id: 1, email: "hello@email.com") {
...
}
}

The fact that this is able to work makes three critical observations/assumptions:

  • the name of the method is also the name of the GQL query/mutation
  • the names of the method arguments are also the names that GQL will use, such as id and email
  • the return type is given by the stub, which is what code autocompletion thinks you are calling. how naïve.

If either of these assumptions do not hold, the code will cry.

I cried regardless.

One additional caveat: the return type specified on the stub contains all fields, not just ones that we requested. This can most certainly be improved in future iterations and is left as an exercise to the reader.

Results

All this said and done, we can finally write a wonderful piece of testing code:

const response = await UserResolver.updateUserEmail(1, "hello@email.com);
expect(response.email).toBe("hello@email.com");

No GQL overhead. Proper types. The code reads exactly like a sentence, allowing developers to focus on writing tests that matter instead of getting stuck in the weedy implementation details.

Don’t forget to turn off the oven when you’re done. Wouldn’t want any fires.

Epilogue

There are a few more components of this testing framework that do not make use of proxies, but are nonetheless still quite interesting. It includes incredibly useful one-line helper functions to abstract the construction of pipelines and claims (a little more on that in this article), addresses the often-leaky lifetimes of test objects (ie. not being destroyed), and tightly integrates with some other aspects of our backend code — all the while, of course, remaining properly typed.

Proxies support a number of other traps other than just get, you can write apply to trap function calls, and even construct to trap new calls. Use knowledge wisely.

Instead of requiring a full proxy object, Javascript also provides getters and setters that perform a limited subset of the same functions a proxy would otherwise provide. This can be used more for simple runtime computations or input validation without the mess of a proxy.

Javascript also provides the Reflect class. I’m not quite sure how to use it, but it seems similar. Go forth and explore!

Was this framework a good idea? Perhaps. Was it fun? Absolutely. Enjoy your dessert.

Sometimes I write things. Get in touch!

--

--