October 4, 2023 - 13 min
Enhancing Utility Functions With JavaScript Proxy
In this article, we’ll dive into JavaScript Proxy objects and make our utility functions even more powerful and maintainable by enhancing our data objects and arrays with Proxy objects.
Building reusable, maintainable, and testable code following the single-responsibility principle has been an industry standard. Part of which involves creating utility functions to help us with simple and repetitive functionalities like formatting, validation, and type safety, just to name a few.
Let’s check out the following example where we use two utility functions to format a user’s full name and bank account balance.
They are so useful, right? Now, let’s say we used these functions in a few dozen files and a few months later, we need to add an extra attribute for these functions or change the return value type. We need to go over all those files and edit the function calls. Utility functions might be very useful and reusable on their own, but maintaining their calls or instances can still be a chore when dealing with major changes.
When we think about our user data example, these two specific utility functions are closely related to the user object and serve as methods to format the raw data we get from the API. It wouldn’t make sense to include the formatted data in the API response, to avoid data duplication and database bloat, but it would be ideal if we could just get formatted data with user.fullName and user.balanceFormatted. That way, we also wouldn’t have to worry about importing and calling the right functions each time we want to get the formatted data.
With JavaScript Proxy objects, we can do just that, and more!
JavaScript Proxy object
JavaScript Proxy object takes a target object and a handler object which contains traps (functions that intercept native object methods) and creates a proxy for that object.
const someProxy = new Proxy(targetObject, handler);
It’s still an object and it works just like you’d expect it to. Let’s pass an empty object to the second argument for now just to see how it behaves just like any other object. We can access all methods and properties of the original object.
const user = {
firstName: "John",
};
const userProxy = new Proxy(user, {});
console.log(userProxy.firstName); // John
userProxy.firstName = "Johnny";
console.log(userProxy.firstName); // Johnny
Where things get interesting is in the second argument – the handler object. This object allows us to intercept and redefine how we interact with the target object. This is also known as metaprogramming. For example, we can hook into the get method and extend the object with new properties without modifying the original object, or we can hook into the set method and add validation to prevent object values being changed if they do not meet validation requirements.
Custom get handler
For now, let’s just add a simple get trap. In the previous example, we used the get method of the original object, but now we are replacing it with our custom traps we have to cover all cases:
- Accessing object properties
- Fallback (error handling)
- Add custom functionality
The get method handler gives us access to two values
- object – original object on which the proxy is attached to
- prop – name of the property that we’re trying to get
const user = {
firstName: "John",
};
const getHandler = (obj, prop) => {
// get object attributes
if(prop in obj) {
return obj[prop];
}
// Extension - Custom attribute
if(prop === "nickname") {
return "Johnny";
}
// Handle error
return undefined;
}
const userProxy = new Proxy(user, {
get: getHandler,
});
console.log(userProxy.firstName); // "John"
console.log(userProxy.nickname); // "Johnny"
console.log(userProxy.age); // undefined
We used a trap in our handle to intercept the call for a nickname property, which doesn’t exist in the original, target object.
You might already get some ideas on how to solve our original problem, but first, let’s rework our code to use Reflect namespace. This complex topic is specific to metaprogramming, but what we need to know is only that we want to use Reflect to invoke the object’s internal methods – Reflect.get, Reflect.set, etc.
const user = {
firstName: "John",
};
const getHandler = (obj, prop) => {
// get object attributes
if(prop in obj) {
return Reflect.get(obj, prop); // Use object's static method
}
// Extension - Custom attribute
if(prop === "nickname") {
return "Johnny";
}
// Handle error
return undefined;
}
const userProxy = new Proxy(user, {
get: getHandler,
});
Same object, multiple interfaces
Going back to our user data example, let’s implement a proxy for our user object to get a formatted full name and account balance. This proxy will make use of our existing utility functions, but now we can get the formatted data directly from the object.
We’ll intercept get calls for two properties: balance (existing property in user object) and fullName (doesn’t exist in user object). We’ll reuse our existing utility functions for formatting and call them in proxy traps. That way, utility functions are not coupled with a proxy handler and remain testable and reusable.
/* UTILS */
const getFullName = (firstName, lastName, middleName) =>
[firstName, middleName, lastName].filter((n) => !!n).join(" ");
const getFormattedBalance = (locale, currency, value) => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency
}).format(value);
};
/* PROXY */
const handleFullGet = (obj, prop) => {
if (prop === "fullName") {
return getFullName(
Reflect.get(obj, "firstName"),
Reflect.get(obj, "lastName"),
Reflect.get(obj, "middleName")
);
}
if (prop === "balance") {
return getFormattedBalance(
Reflect.get(obj, "locale"),
Reflect.get(obj, "currency"),
Reflect.get(obj, "balance")
);
}
if (!(prop in obj)) {
console.error(`${prop.toString()} doesn't exist in object`, obj);
return;
}
return Reflect.get(obj, prop);
};
const createUserDataProxy = (user) => {
const proxy = new Proxy(user, {
get: handleFullGet
});
return proxy;
};
/* USE */
const user = createUserDataProxy({
firstName: "John",
middleName: "",
lastName: "Doe",
email: "john.doe@email.com",
balance: 2649.53,
currency: "USD",
locale: "en-US"
});
console.log(user.fullName); // John Doe
console.log(user.balance); // $2,649.53
Let’s test out our new proxy. Now we don’t have to rely on calling the right utility function and passing the right data each time we want to get the formatted value. Also, our utility function calls are more maintainable since they’re called only in our traps. We only have to apply the proxy to the user object and we have access to the formatted values right away.
Let’s take this a step further. We also have a new, redacted view of user data, where the middle name and last name have been shortened to a single letter, and the email address is obfuscated.
If we relied on utility functions exclusively, we would have ended up with a bunch of functions just for formatting the user data and we’d have to import them and call the right one depending on the use case (full or redacted data). We’d also have to make sure we don’t show any sensitive user data on accident.
With Proxy objects, we can also prevent access by returning a default value or throwing an error. In the previous example, we’ve been very lenient with returning a value, let’s take a more restrictive approach here by allowing access to only a handful of object properties.
/* UTILS */
const getFullName = (firstName, lastName, middleName) =>
[firstName, middleName, lastName].filter((n) => !!n).join(" ");
const getFormattedBalance = (locale, currency, value) => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency
}).format(value);
};
const getRedactedEmail = (email) => {
const [name, ...rest] = email.split("@");
const [service, domain] = rest.join("").split(".");
const redactedName = `${name[0]}*****${name[name.length - 1]}`;
const redactedDomain = `${service[0]}***${
service[service.length - 1]
}.${domain}`;
return `${redactedName}@${redactedDomain}`;
};
const getRedactedName = (name) => (Boolean(name.length) ? `${name[0]}.` : "");
/* PROXY */
const handleRedactedGet = (obj, prop) => {
if (prop === "fullName") {
return getFullName(
Reflect.get(obj, "firstName"),
getRedactedName(Reflect.get(obj, "lastName")),
getRedactedName(Reflect.get(obj, "middleName") || "")
);
}
if (prop === "middleName") {
return getRedactedName(Reflect.get(obj, "middleName"));
}
if (prop === "lastName") {
return getRedactedName(Reflect.get(obj, "lastName"));
}
if (prop === "email") {
return getRedactedEmail(Reflect.get(obj, "email"));
}
if (!(prop in obj)) {
console.error(`${prop} doesn't exist in object`, obj);
return;
}
return "Data not available";
};
const createRedactedDataProxy = (user) => {
const proxy = new Proxy(user, {
get: handleRedactedGet
});
return proxy;
};
/* USE */
const user = createRedactedDataProxy({
firstName: "John",
middleName: "",
lastName: "Doe",
email: "john.doe@email.com",
balance: 2649.53,
currency: "USD",
locale: "en-US"
});
console.log(user.fullName); // John D.
console.log(user.email); // j*****e@e***l.com
console.log(user.balance); // 'Data not available'
If we use a framework like React for rendering our data with UI components and if we use TypeScript for type checking, we can create components that accept only data objects with a specific proxy applied. That way, we can make sure that UI components that render out redacted data cannot access any unredacted values and have data formatting already handled inside the applied proxy.
Here is the full demo and source code for this example if you want to have a look and try it out for yourself.
Custom set handler
So far, we’ve worked with get method traps, so let’s have a look at set method traps. In addition to object and prop attributes, Proxy gives us access to the value we want to write, so we can implement custom validation and formatting before saving the value.
In this example, we’ll implement a very simple document editor with history and an undo function. You are probably imagining how many variables and functions would it take to manage document history and keep track of active value, but we’ll use a Proxy to further simplify the problem.
Let’s start with the set method. We are going to add a side effect. On each value change we’ll add the previous value to the history array and we’ll also automatically keep track of the timestamp when the value has been saved.
Let’s add a simple validation and restrict the content to 255 characters.
/* PROXY */
const handleSet = (obj, prop, value) => {
if (prop === "content" && value.length > 0 && value.length < 255) {
const { content, lastUpdated, history = [] } = obj;
const historyUpdated = [...history, { content, lastUpdated }];
if (content && lastUpdated) {
Reflect.set(obj, "history", historyUpdated);
}
Reflect.set(obj, prop, value);
Reflect.set(obj, "lastUpdated", Date.now());
return true;
}
console.error(
"Content length should not be empty and be less than 255 characters"
);
return false;
};
const createDocProxy = (doc) => {
return new Proxy(doc, {
set: handleSet
});
};
/* INITIALIZE */
const doc = createDocProxy({
content: "",
lastUpdated: Date.now()
});
You might have already come up with the solution which involves the history array that we’ve created in the previous step, but let’s utilize the trap and let it manage everything for us.
/* PROXY */
const handleGet = (obj, prop) => {
if (prop === "undo") {
const last = obj.history.pop();
if (!last) {
return;
}
const { content, lastUpdated } = last;
Reflect.set(obj, "content", content);
Reflect.set(obj, "lastUpdated", lastUpdated);
return { content, lastUpdated };
}
return Reflect.get(obj, prop);
};
const handleSet = (obj, prop, value) => {
if (prop === "content" && value.length > 0 && value.length < 255) {
const { content, lastUpdated, history = [] } = obj;
const historyUpdated = [...history, { content, lastUpdated }];
if (content && lastUpdated) {
Reflect.set(obj, "history", historyUpdated);
}
Reflect.set(obj, prop, value);
Reflect.set(obj, "lastUpdated", Date.now());
return true;
}
console.error(
"Content length should not be empty and be less than 255 characters"
);
return false;
};
const createDocProxy = (doc) => {
return new Proxy(doc, {
get: handleGet,
set: handleSet
});
};
/* INITIALIZE */
const doc = createDocProxy({
content: "",
lastUpdated: Date.now()
});
/* EVENTS */
const input = document.getElementById("data-input");
function save() {
const value = input.value;
doc.content = value;
}
function undo() {
const previous = doc.undo;
if (!previous) {
return;
}
input.value = doc.content;
}
When accessing the newly created undo property, we are returning the previous document state and updating the current values of the document object. Notice how we’re only reading and writing values, and Proxy manages everything else for us internally.
Imagine how much work would it take to keep track and manage history in a more complex use case where a document could be updated from multiple places in the UI. By keeping these functions close to the target object and intercepting the set method, we can freely update the document content from any place we like and not worry about managing those additional states. This also makes the code more maintainable if, for example, we need to add a new document state and manage it accordingly.
What about TypeScript?
This looks like a nightmare scenario for any TypeScript developer – we’re adding properties that are not available in the target object, we can change the object data type, we’re limiting access to properties…
To make things even more complicated, TypeScript’s Proxy definition infers that the returned Proxy object is of the same type as the target object, meaning that TypeScript will throw an error if we try to access any properties that we’ve added in the proxy object.
Luckily, there are multiple ways around this problem. Let’s have a look at our user object example where we are redefining the account balance property and adding a full name property.
We can add new properties to the original object interface and mark them as optional. But that’s not accurate and it’s not entirely helpful for type checking.
Alternatively, we can use casting to override the type when we apply the proxy. We can assume the type is accurate, but converting it to the unknown type makes it difficult to debug as we are enforcing the type.
interface User {
firstName: string;
middleName?: string;
lastName: string;
email: string;
balance: number;
currency: string;
locale: string;
}
interface UserProxy extends User {
fullName: string; // new property
balance: string; // redefined propery
}
const handleGet = (obj: User, prop: string | symbol) => {
/* ... */
};
export const createUserDataProxy = (user: User): UserProxy => {
const proxy = new Proxy(user, {
get: handleGet,
}) as unknown as UserProxy;
return proxy;
};
And lastly, which seems to be the best option, we can write an adapter function to convert the User type into a UserProxy before passing the object to the Proxy. We only need to cast the redefined properties (balance property in our case) and simply add new properties with default values to the modified object. That way we can ensure type safety with minimal casting.
interface User {
firstName: string;
middleName?: string;
lastName: string;
email: string;
balance: number;
currency: string;
locale: string;
}
interface UserProxy extends User {
fullName: string;
balance: string;
}
const adapter = (user: User) => {
// Redefined properties
const balance = user.balance as unknown as string;
// New properties
const newProps = {
fullName: "",
};
return { ...user, ...newProps, balance };
};
const handleGet = (obj: UserProxy, prop: string | symbol) => {
/* ... */
};
export const createUserDataProxy = (user: User): UserProxy => {
const proxy = new Proxy(adapter(user), {
get: handleGet,
});
return proxy;
};
If you want to completely avoid casting types, I would recommend instead of redefining existing property types, to add new properties which have the correct type. In our user example, we can add a new property balanceFormatted (which is a string) instead of the existing balance property (which is a number).
interface User {
firstName: string;
middleName?: string;
lastName: string;
email: string;
balance: number;
currency: string;
locale: string;
}
interface UserProxy extends User {
fullName: string;
balanceFormatted: string; // New property, no longer redefined
}
const adapter = (user: User) => {
// New properties
const newProps = {
fullName: "",
balanceFormatted: ""
};
return { ...user, ...newProps };
};
const handleGet = (obj: UserProxy, prop: string | symbol) => {
/* ... */
};
export const createUserDataProxy = (user: User): UserProxy => {
const proxy = new Proxy(adapter(user), {
get: handleGet,
});
return proxy;
};
Applying Proxy objects to arrays
Array is a special type of an object and we can create proxy objects for them, too.
Let’s create a proxy for an array that will take up to 10 numbers (validation with a set trap), we’ll allow accessing a value on each index and we’ll add a property average that will return the average value of all numbers in the array (new property on a get trap).
/* UTILS */
const getAverageScore = (score) =>
score.length ? score.reduce((acc, point) => acc + point) / score.length : 0;
/* PROXY */
const handleSet = (obj, prop, value) => {
const isNumber = !isNaN(parseInt(prop));
if (isNumber) {
const index = parseInt(prop);
console.log(index);
if (index >= 10) {
throw new RangeError("Array limit reached");
}
Reflect.set(obj, prop, value);
}
Reflect.set(obj, prop, value);
return true;
};
const handleGet = (obj, prop) => {
if (prop === "average") {
return getAverageScore(obj);
}
if (prop in obj) {
return Reflect.get(obj, prop);
}
};
const createArrayProxy = (arr) => {
const proxy = new Proxy(arr, {
set: handleSet,
get: handleGet
});
return proxy;
};
/* INIT */
let arr = createArrayProxy([]);
Instead of a gracious fallback, we’ll throw an error if we attempt to have more than 10 numbers in the array. We can then use try-catch to handle it.
String index values in arrays
In this final example, we’ll extend our array functionality to accept string index values. We’ll use the string index value to filter the array and return the final value. We can still use numerical index values to access individual array elements.
We’ll have a list of malls that offer various services, and we’ll use string index value to filter malls to include only elements that have the specified service.
/* SAMPLE DATA */
const DATA = [
{
id: "1",
name: "Super mega mall",
address: "Some address 1",
city: "Super Mega City",
services: ["restaurant", "caffee", "shops", "cinema", "arcade"]
},
{
id: "2",
name: "Super cool mall",
address: "Some address 2",
city: "Super Cool City",
services: ["caffee", "shops", "cinema", "arcade"]
},
{
id: "3",
name: "Super medium mall",
address: "Some address 3",
city: "Super Medium City",
services: ["caffee", "shops", "arcade"]
},
{
id: "4",
name: "Super small mall",
address: "Some address 4",
city: "Small Town",
services: ["caffee", "shops"]
},
{
id: "5",
name: "Just shops mall",
address: "Some address 5",
city: "Really Small Town",
services: ["shops"]
},
{
id: "6",
name: "Party town arcade",
address: "Some address 6",
city: "Some Other Town",
services: ["caffee", "restaurants", "arcade"]
}
];
const FILTERS = [
"all",
"restaurant",
"caffee",
"shops",
"cinema",
"arcade"
];
/* PROXY */
const isService = (prop) => FILTERS.includes(prop.toString());
const handleGet = (obj, prop) => {
if (prop in obj) {
return Reflect.get(obj, prop);
}
if (prop === "all") {
return obj;
}
if (isService(prop)) {
return obj.filter(({ services }) => services.includes(prop));
}
return undefined;
};
const createStoresProxy = (stores) => {
const proxy = new Proxy(stores, {
get: handleGet
});
return proxy;
};
/* INIT */
const stores = createStoresProxy(DATA);
Conclusion
When dealing with utility functions that are closely related to data objects or arrays, we can create a proxy that utilizes them and change how data is formatted, validated, read, written, etc.
Using proxies in this way reduces the amount of utility function imports which makes them more maintainable while keeping the testability and single-responsibility principle of the original utility function. When combined with TypeScript, we can consume data that has a specific proxy attached to it which, in turn, allows for better developer experience, and a cleaner and more maintainable code.
In terms of performance, Proxy objects are the least performant out of other possible approaches, so developers shouldn’t rely on them for parts of the code where performance is the priority.
JavaScript Proxy objects shouldn’t be considered a replacement for utility functions and should be treated as an enhancement to our utility functions, and used in specific use cases like formatting, validation, adding side-effects to read/write, etc.
Give Kudos by sharing the post!