Proxies
Javascript Proxies are a powerful concept in ES6. They give developers more control over JS objects, paving the way for declarative API design. I am going to show some examples of how use proxies to extend the functionality of traditional Javascript objects and why they are useful in understanding the flow of data inside your programs.
Microservice Client
The first example comes from a request management client that uses a JSON schema to validate incoming requests and route them to other services. The code for its implementation is here. Proxies are valuable in this context since our request client’s API reflects our schema, thus allowing the schema to serve as both validation and documentation. Another advantage to schema driven API definitions is that automated tools are able to generate and update the schemas based on microservice API docs, minimizing the work needed to stay in sync with other services to which we are making requests.
Designing The API
Since this a client for managing requests to different microservices, we want the API to reflect the supported HTTP methods and service name of each URL. A call to a posts service looks like this:
const mySchema = {
posts: {
methods: ["get", "post"],
path: template("/"),
bodyValidator: {
id: "updateProduct",
type: "object",
properties: {
title: { type: "string" },
author: { type: "string" },
content: { type: "string" }
}
},
paramsValidator: {
id: "sortBy",
type: "object",
properties: {
id: { type: "string" }
}
}
}
}
// The Manager class shown in the next example, request adaptors are not covered in this post
// but more information can be found in the repo link above.
const postsClient = new Manager('http://posts.some-service.net', axiosAdapter).validateWith(mySchema)
const getRequest = myclient.posts().get({sortBy: 'newest'})
getRequest.then(res => res.json()).then(console.log)
const postRequest = myclient.posts().post({title: 'why proxies are cool', author: 'taylor thompson', content: 'proxies are cool because they give you superpowers.'})
postRequest.then(res => res.json()).then(console.log)
The question then becomes, “how does our request client reflect the schema without manually typing out all the fields?”. This is where proxies come in to play. Let’s look at a stripped down implementation of the Manager
class:
class Manager {
constructor(baseURL, adaptor) {
this.baseURL = baseURL
this.adaptor = adaptor
}
validateWith(schema) {
const self = this; // Added to prevent confusion with 'this' when nesting calls
return accessInterceptor(function(_, schemaPath) {
if (!(schemaPath in schema)) {
// custom defined error
throw new PathNotInSchemaError(`${schemaPath} not in schema.\n valid paths are\n [${Object.keys(schema)}]`)
}
return specifier => accessInterceptor(function(_, method){
const supportedMethods = schema[schemaPath].methods
if (!supportedMethods.includes(method)) {
throw new MethodNotSupportedError(`${method} not supported in ${schemaPath}.\n supported methods are \n [${Object.values(supportedMethods)}]`)
}
})
// continued below
// ...
})
}
}
function accessInterceptor(interceptor) {
return new Proxy({}, {
get(target, property) {
return interceptor(target, property)
}
}
)
}
Much of the heavy lifting in the Manager
class is performed by the accessInterceptor
function. It takes a function as an argument and returns a proxy. The interceptor function is called by the proxy whenever we try to access a property of the target object (which in this case is just an empty object). Using the interceptors on property access allows the object to dynamically take whatever shape we want, which in snippet above, is managerClassInstance.schemaPath.httpMethod
. Because the first interceptor function checks whether or not the property attempting to be accessed is defined in the provided schema, if we were to try and access a non-existent property on the example schema like users
, the Manager
class instance throws a PathNotInSchemaError
.
By using the accessInterceptor
function, we are able to chain methods according to our schema. If our schema changes, the methods available on the Manager
class instance also change. This enforces parity between our code and our documentation (in this case the schemas are self documenting).
Object Access Interception
Being able to introspect the activity of your data structures is useful. For example, if you want to trace all the get
and set
operations on an object, proxies are here to help:
const dataStore = {
users: [{username: 'user123', id: '3a34cb03s'}, {username: 'tester456', id: '6kj77acv9'}],
posts: [{title: 'some cool post', desc: 'a great post about javascript'}],
//....
}
const handler = {
get(...args){
console.log('%cGETTING WITH: %o', 'color: purple;', ...args);
return Reflect.get(...args)
},
set(...args){
console.log('%cSETTING WITH: %o', 'color: blue;', ...args);
return Reflect.set(...args)
}
}
const dataStoreWithTracingEnabled = new Proxy(dataStore, handler)
In this example, when you assign or access property values to the dataStoreWithTracingEnabled
variable, you see the target, property, value, and receiver in the console. If you set the new property onlineStatus
by dataStoreWithTracingEnabled.onlineStatus = 'busy'
, you see the following in your console:
- Our message:
SETTING WITH:
- the target (the datastore object):
{
users: [{username: 'user123', id: '3a34cb03s'}, {username: 'tester456', id: '6kj77acv9'}],
posts: [{title: 'some cool post', desc: 'a great post about javascript'}],
onlineStatus: 'busy'
}
- the property:
onlineStatus
- the value:
'busy'
- receiver:
Proxy
<target>: Object {
users: [{username: 'user123', id: '3a34cb03s'}, {username: 'tester456', id: '6kj77acv9'}],
posts: [{title: 'some cool post', desc: 'a great post about javascript'}],
onlineStatus: 'busy'
},
<handler>: Object {
get: get(args),
set: set(args)
}
Intercepting Object properties is not only useful for introspection, but also for creating user friendly abstractions. Some interesting libraries that use proxies are: immer, uses proxies to produce immutable datastructures from an API that follows Javascript’s mutable Object methods, objecthistory which enables undo and redo for values assigned to objects, and echo, which uses proxies similarly to the above example, logging the evaluation of the code you type in the console. Some ideas where to use proxies in your current codebase: sending telemetry, better request logging, input sanitization, validating requests, and broadcasting state changes (a lightweight alternative to observables).
Additional Resources
For more information on how proxies work, mdn as always is a great reference, as well as the section meta programming with proxies from Dr. Axel Rauschmayer’s excellent book, Exploring ES6.
Top comments (0)