Tried Proxy yet...?

Harpreet SinghHarpreet Singh
15 min read

Introduction

As the name suggests, a Proxy is an intermediary that acts on behalf of another entity to perform tasks or relay information.

I first encountered the power of Proxy while exploring the Vue.js reactivity model.This experience opened my eyes to its versatility and potential. Although modern frameworks are increasingly adopting Signals for reactivity, Proxy remains highly relevant and valuable.

When I first delved into JS Proxy, I was immediately captivated by its ease of usage, flexibility and potential use-cases. After grasping the basics, I eagerly applied it in my projects, discovering two distinct ways to enhance my work. Initially, I felt accomplished, but as I explored the broader community, I realized that I had only scratched the surface. The possibilities with JS Proxy extend far beyond what I had imagined.

In this article we will explore:

  • How Proxy acts as a shape-shifter?

  • How Proxy is better then setter/getters?

  • Some real-world practical use cases along with code examples

  • And much more!

What is a Proxy in JS?

A Proxy object allows you to create a wrapper around another object, intercepting and redefining fundamental operations for that object, such as property lookup, assignment, enumeration, function invocation, and others.

Description

Proxy can be viewed as an advanced extension of setters and getters—a super-set that provides a broader scope of control over object operations. While getters and setters focus on intercepting access to specific properties, Proxy offers a comprehensive mechanism for intercepting a wide range of fundamental operations not just get, set but has, deleteProperty, apply and many more JavaScript operations.

Create a Proxy

const myProxy = new Proxy(target, handler);

A Proxy in JavaScript is created using two parameters:

  1. target: The original object that you want to proxy.

  2. handler: An object that specifies which operations on the target will be intercepted(using traps) and how these operations should be customized.

Code Example

The following code demonstrates how to create a Proxy for a target Object:

const person = {
    firstName: 'John',
    lastName: 'Doe',
};

const handler = {
    get(target, prop) {
        if (prop === 'firstName') {
            return target[prop].toUpperCase();
        }
        return target[prop];
    },
    set(target, prop, value) {
        if (prop === 'firstName') {
            target[prop] = value.toUpperCase();
        }
        // Indicate success
        return true;
    },
};

const proxyUser = new Proxy(person, handler);

// invokes the `get` trap
console.log(proxyUser.firstName); // JOHN
console.log(proxyUser.lastName); // Doe

// invokes the `set` trap
proxyUser.firstName = 'jane'
console.log(proxyUser.firstName); // JANE
console.log(proxyUser.lastName); // Doe

Proxy vs Setter/Getter

You might wonder you can achieve the same result using setters and getters. So, what are the advantages of using a Proxy instead?

To understand this lets see how the above code will look if we use setter/getter

const person = {
    _firstName: 'John', // this is not `firstName` notice the `_`
    _lastName: 'Doe', // this is not `lastName` notice the `_`
    // we can't use firstName or lastName as it will cause
    // RangeError: Maximum call stack size exceeded

    get firstName() {
        return this._firstName.toUpperCase();
    },

    set firstName(value) {
        this._firstName = value.toUpperCase();
    },

    get lastName() {
        return this._lastName;
    },

    set lastName(value) {
        this._lastName = value;
    }
};

console.log(person.firstName); // JOHN
console.log(person.lastName); // Doe

person.firstName = 'jane'
console.log(person.firstName); // JANE
console.log(person.lastName); // Doe

After comparing both the examples, here are the some key advantages of using JavaScript Proxy over traditional getter/setter methods:

  1. We don't need to change the original object

  2. We can re-use same logic for accessing different properties. With setter/getter we have to define a new set and get method for different properties

  3. We can create multiple Proxies for the same object, each tailored for different use-cases. For example, we can create a different proxy that can return firstName in camel-case. Another scenario where multiple proxies would be useful (e.g., different validation rules for different environments).

  4. Proxy is not limited to just set and get, it can intercept other methods using traps like delete and function invocations, which aren't covered by traditional getters/setters.

What is a trap?

The function that define the behavior for the corresponding object internal method.(This is analogous to the concept of traps in operating systems.)

from MDN

In simple terms: Traps(gatekeepers) are functions in the Proxy handler that define how operations (like getting or setting properties) are intercepted and customized.

In the above example, the handler defined two traps the set and get to intercept the [[GET]] and [[SET]] method of the target object. Similarly, Proxy supports other traps that can intercept other internal methods of the Object.

Here is a comprehensive list of traps supported by the Proxy:

  1. get: Intercepts getting a property value.

  2. set: Intercepts setting a property value.

  3. has: Intercepts the in operator.

  4. deleteProperty: Intercepts the delete operator.

  5. apply: Intercepts function calls.

  6. construct: Intercepts the new operator.

  7. getOwnPropertyDescriptor: Intercepts Object.getOwnPropertyDescriptor.

  8. defineProperty: Intercepts Object.defineProperty.

  9. getPrototypeOf: Intercepts Object.getPrototypeOf.

  10. setPrototypeOf: Intercepts Object.setPrototypeOf.

  11. isExtensible: Intercepts Object.isExtensible.

  12. preventExtensions: Intercepts Object.preventExtensions.

  13. ownKeys: Intercepts Object.getOwnPropertyNames, Object.getOwnPropertySymbols, and Object.keys.

  14. hasOwn: Intercepts Object.hasOwn (available starting ES2022).

The list above clearly defines the scope upto which proxy can be used to target and proxy different JS object internals. Each has their own kind of classic real world use case.

Proxy as a Shape-Shifter

Consider two JSONs mentioned below

// Config 1 - dualLanguageConfig
{
    "configType": "dual",
        "primary": {
        "isDefault": true,
            "languageCode": "en-US",
                "supportedFormats": [
                    "text",
                    "html",
                    "markdown"
                ],
                    "translationConfig": {
            "autoTranslate": true,
                "provider": "GoogleTranslate",
                    "cacheDuration": 3600,
                        "fallbackLanguage": "en"
        }
    },
    "secondary": {
        "isDefault": false,
            "languageCode": "es-ES",
                "supportedFormats": [
                    "text",
                    "html"
                ],
                    "translationConfig": {
            "autoTranslate": false,
                "provider": "None",
                    "cacheDuration": 0,
                        "fallbackLanguage": "en"
        }
    }
}

// Config 2 - singleLanguageConfig
{
    "configType": "single",
        "primary": {
        "isDefault": true,
            "languageCode": "en-US",
                "supportedFormats": [
                    "text",
                    "html",
                    "markdown"
                ],
                    "translationConfig": {
            "autoTranslate": true,
                "provider": "GoogleTranslate",
                    "cacheDuration": 3600,
                        "fallbackLanguage": "en"
        }
    },
    "secondary": {
    }
}

To access all the nested properties in such a JSON structure, you have two main options:

  1. You can either write a wrapper module with functions for each property or

  2. Use dot notation to directly access deeply nested properties like config.secondary.translationConfig.autoTranslate.

However, since the secondary config is only defined when configType is dual, direct property access requires checking type first and then adding null checks. While you could build these checks into a wrapper function, a Proxy allows you to handle these scenarios seamlessly, enabling you to safely access properties without the need for additional function calls or manual checks.

const handleLanguageConfig = {
    get(target, prop) {
        const { type, primary, secondary } = target;
        if (type === 'single') {
            return primary[prop]
        }
        // handle all dual configurations
        if (prop === 'supportedFormats') {
            // return unique supported formats only
            return [...new Set([...primary.supportedFormats, ...secondary.supportedFormats])];
        }
        return [primary, secondary].map(config => config[prop]);
    },
};

const proxyConfigDual = new Proxy(dual, handleLanguageConfig);
const proxyConfigSingle = new Proxy(single, handleLanguageConfig);

console.log(proxyConfigDual.languageCode); // ["en-US", "es-ES"]
console.log(proxyConfigSingle.languageCode); // "en-US"

console.log(proxyConfigDual.supportedFormats); // ["text", "html", "markdown", "csv"]
console.log(proxyConfigSingle.supportedFormats); // ["text", "html", "csv"]

In the example above, we effortlessly manage property access based on type and conditionally process values as needed. The supportedFormats property is accessed directly, yet we ensure unique values by handling its specific case conditionally.

Regardless of the config type provided, we automatically retrieve the correct values by simply accessing the property, rather than calling a wrapper function. The logic that determines the values based on type is encapsulated in an independent handler, handleLanguageConfig. This approach allows us to create additional handlers and apply them with a proxy, all without altering the original config object.

const handleLanguageConfig = {
    get(target, prop) {
        const { type, primary, secondary } = target;
        if (type === 'single') {
            return primary[prop]
        }
        // handle all dual configurations
        if (prop === 'supportedFormats') {
            // return unique supported formats only
            return [...new Set([...primary.supportedFormats, ...secondary.supportedFormats])];
        }
        return [primary, secondary].map(config => config[prop]);
    },
};

const handleLanguageConfig2 = {
    get(target, prop) {
        const { type, primary, secondary } = target;
        // handle all dual configurations
        if (prop === 'supportedFormats') {
            // return unique supported formats only
            return [...new Set([...primary.supportedFormats, ...secondary.supportedFormats])];
        }
        return [primary, secondary].map(config => config[prop]);
    },
};

const proxyConfigOld = new Proxy(single, handleLanguageConfig);
const proxyConfigNew = new Proxy(single, handleLanguageConfig2);

console.log(proxyConfigOld.languageCode); // "en-US"
// here we are getting languageCode in an Array as well
console.log(proxyConfigNew.languageCode); // ["en-US"]

By providing the appropriate proxy handler, we can override and customize the behavior of the proxied object.

Proxies in Action: Practical Use Cases

A Proxy can adjust its behavior based on the needs of the application. Whether it's controlling access to object properties, modifying data dynamically, or handling complex validation logic, a Proxy can transform how your objects interact with the rest of the system. This flexibility isn't just theoretical—it solves real-world problems developers face every day.

Below are some Proxy's Real world use cases:

  1. Detect Change

  2. Reactivity

  3. Validate Input

  4. Auditing and Logging

  5. Protect Sensitive Data

  6. Garbage Collection and Memory Management

  7. Soft Deletion

  8. Manage Defaults and Missing properties

  9. Rate Limiter

  10. Profiling

  11. Access Nested Objects in a cleaner way

  12. and many more you can think of...

Check this awesome list of what others have developed using proxy https://github.com/mikaelbr/awesome-es2015-proxy?tab=readme-ov-file#modules

Lets explore some code examples of the scenarios mentioned above

Code examples

  1. Detect Change

     const target = {
         name: 'John Doe',
         age: 30
     };
    
     const handler = {
         get: function (obj, prop) {
             console.log(`Property ${prop} accessed with value ${obj[prop]}`);
             return obj[prop];
         },
         set: function (obj, prop, value) {
             console.log(`Property ${prop} changed from ${obj[prop]} to ${value}`);
             obj[prop] = value;
             return true;
         },
         deleteProperty: function (obj, prop) {
             console.log(`Property ${prop} deleted`);
             delete obj[prop];
             return true;
         }
     };
    
     const proxy = new Proxy(target, handler);
    
     // Accessing properties
     console.log(proxy.name); // Logs: Property name accessed with value John Doe
    
     // Changing properties
     proxy.age = 31; // Logs: Property age changed from 30 to 31
    
     // Deleting properties
     delete proxy.name; // Logs: Property name deleted
    
    1. get Trap: Logs a message when a property is accessed.

    2. set Trap: Logs a message when a property is assigned a new value.

    3. deleteProperty Trap: Logs a message when a property is deleted.

By using a Proxy, you can effectively monitor and react to changes in an object's properties, making it a powerful tool for various use cases such as state management, debugging, and more.

  1. Reactivity

     // Function to update the DOM
     function updateDOM(state) {
         document.getElementById('name').textContent = `Name: ${state.name}`;
         document.getElementById('age').textContent = `Age: ${state.age}`;
     }
    
     // Initial state
     const state = {
         name: 'John Doe',
         age: 30
     };
    
     // Handler for the Proxy
     const handler = {
         set: function (obj, prop, value) {
             obj[prop] = value;
             updateDOM(obj); // Update the DOM whenever the state changes
             return true;
         }
     };
    
     // Create a Proxy for the state
     const proxyState = new Proxy(state, handler);
    
     // Initial DOM update
     updateDOM(proxyState);
    
     // Event listeners for buttons
     document.getElementById('updateName').addEventListener('click', () => {
         proxyState.name = 'Jane Doe'; // This will trigger the DOM update
     });
    
     document.getElementById('updateAge').addEventListener('click', () => {
         proxyState.age = 31; // This will trigger the DOM update
     });
    

    By using a Proxy, you can create a simple reactive system that automatically updates the DOM when the state changes, demonstrating the concept of reactivity in JavaScript.

  2. Validate Input

     const user = {
         name: '',
         age: 0
     };
    
     const handler = {
         set(target, property, value) {
             if (property === 'name') {
                 if (typeof value !== 'string' || value.trim() === '') {
                     console.log('Invalid name. It must be a non-empty string.');
                     return false;
                 }
             }
             if (property === 'age') {
                 if (typeof value !== 'number' || value <= 0) {
                     console.log('Invalid age. It must be a positive number.');
                     return false;
                 }
             }
             target[property] = value;
             return true;
         }
     };
    
     const proxyUser = new Proxy(user, handler);
    
     // Valid input
     proxyUser.name = 'Alice'; // Sets the name to 'Alice'
     proxyUser.age = 30; // Sets the age to 30
    
     // Invalid input
     proxyUser.name = ''; // Logs: Invalid name. It must be a non-empty string.
     proxyUser.age = -5; // Logs: Invalid age. It must be a positive number.
    
     console.log(proxyUser); // { name: 'Alice', age: 30 }
    

    The set trap in the handler performs the validation and controls whether the property assignment is allowed

  3. Auditing and Logging

     const data = { username: 'john_doe', email: 'john@example.com' };
    
     const handler = {
         deleteProperty(target, property) {
             console.log(`Property '${property}' was deleted`);
             return delete target[property];
         }
     };
    
     const proxyData = new Proxy(data, handler);
    
     delete proxyData.email; // Logs: Property 'email' was deleted
    

    The code uses a proxy to intercept and log deletions of properties from the data object. When proxyData.email is deleted, it logs the deletion and then removes the property.

  4. Protect Sensitive Data

     const user = { id: 1, username: 'admin', role: 'superuser' };
    
     const handler = {
         deleteProperty(target, property) {
             if (property === 'role') {
                 console.log(`Cannot delete critical property: ${property}`);
                 return false;
             }
             return delete target[property];
         }
     };
    
     const proxyUser = new Proxy(user, handler);
    
     delete proxyUser.role; // Cannot delete critical property: role
     delete proxyUser.username; // Property is deleted normally
    

    The code uses a proxy to prevent the deletion of the role property from the user object, logging a message and returning false if attempted. Other properties, like username, can be deleted normally.

  5. Garbage Collection and Memory Management

     const resources = { file1: 'content1', file2: 'content2' };
    
     const handler = {
         deleteProperty(target, property) {
             console.log(`Releasing resources associated with ${property}`);
             // Perform cleanup tasks
             return delete target[property];
         }
     };
    
     const proxyResources = new Proxy(resources, handler);
    
     delete proxyResources.file1; // Logs: Releasing resources associated with file1
    

    In this example, a Proxy is created around the resources object to intercept the deletion of properties. When a property is deleted, the deleteProperty trap logs a message and can perform cleanup tasks before removing the specified property from the target object.

  6. Soft Deletion

     const product = { id: 101, name: 'Smartphone', isDeleted: false };
    
     const handler = {
         deleteProperty(target, property) {
             if (property === 'name') {
                 console.log('Soft deleting the product.');
                 target.isDeleted = true;
                 return true; // Indicate success
             }
             return delete target[property];
         }
     };
    
     const proxyProduct = new Proxy(product, handler);
    
     delete proxyProduct.name; // Also Logs: Soft deleting the product.
     console.log(product.isDeleted); // true
    

    In this example, when attempting to delete the name property from the product object via the proxy, it triggers a soft delete by setting isDeleted to true while logging a message instead of actually removing the property.

  7. Manage Defaults and Missing properties

     const defaultValues = {
         name: 'Unknown',
         age: 0,
         country: 'Unknown'
     };
    
     const handler = {
         get(target, prop) {
             // If the property doesn't exist, return a default value
             return prop in target ? target[prop] : defaultValues[prop];
         }
     };
    
     const person = {
         name: 'John',
         country: 'USA'
     };
    
     const proxyPerson = new Proxy(person, handler);
    
     console.log(proxyPerson.name);    // John
     console.log(proxyPerson.age);     // 0 (default)
     console.log(proxyPerson.country); // USA
    

    In this example, the proxy intercepts property access on the person object, returning the actual value for existing properties like name and country. For properties not found in the person object, such as age, it provides a default value from the defaultValues object, which is 0.

  8. Rate Limiter

     const rateLimiter = (fn, limit) => {
         let calls = 0;
    
         return new Proxy(fn, {
             apply(target, thisArg, argumentsList) {
                 if (calls >= limit) {
                     console.log('Rate limit exceeded. Try again later.');
                     return;
                 }
                 calls++;
                 return target.apply(thisArg, argumentsList);
             }
         });
     };
    
     const apiCall = () => {
         console.log('API call made');
     };
    
     const limitedApiCall = rateLimiter(apiCall, 3);
    
     // Simulate multiple API calls
     limitedApiCall(); // API call made
     limitedApiCall(); // API call made
     limitedApiCall(); // API call made
     limitedApiCall(); // Rate limit exceeded. Try again later.
    

    This Proxy-based rate limiter intercepts function calls and restricts them to a specified limit. If the limit is exceeded, it blocks further calls and displays a message instead of executing the function.

  9. Profiling - Function Execution Time

    function expensiveOperation() {
        for (let i = 0; i < 1e6; i++) { } // Simulate a costly operation
        return "Operation Complete";
    }
    
    const handler = {
        apply(target, thisArg, args) {
            console.time('Execution Time');
            const result = target.apply(thisArg, args);
            console.timeEnd('Execution Time');
            return result;
        }
    };
    
    const proxyOperation = new Proxy(expensiveOperation, handler);
    
    console.log(proxyOperation()); // Logs execution time and result
    

    The example above shows how to profile function execution by measuring how long a function takes to execute using a Proxy - the apply trap intercepts the function call and allows us to measure how long the expensiveOperation function takes to execute using console.time and console.timeEnd

  10. Access Nested Objects in a cleaner way

    const user = {
        name: 'John Doe',
        address: {
            street: '123 Main St',
            city: 'New York',
            zip: '10001',
        },
    };
    
    const fullAddressHandler = {
        get(target, prop) {
            if (prop === 'address') {
                const { street, city, zip } = target.address;
                return `${street}, ${city}, ${zip}`;
            }
            return target[prop];
        },
    };
    
    const proxiedUser = new Proxy(user, fullAddressHandler);
    
    console.log(proxiedUser.address); // 123 Main St, New York, 10001
    

    The address property itself has been overridden via the Proxy to return the full address string instead of the nested object. This allows you to access the full address using proxiedUser.address directly

Performance Issues with Proxy

Using proxies can introduce performance overhead due to the additional layer of indirection and the traps that are executed for each intercepted operation. Here are some common performance issues associated with proxies:

  1. Overhead of Traps: Each operation (e.g., get, set, delete) on the proxy object triggers the corresponding trap in the handler. This can add significant overhead, especially if the operations are frequent.

  2. Complexity of Traps: If the logic inside the traps is complex or involves heavy computations, it can further degrade performance.

  3. Lack of Optimization: JavaScript engines are highly optimized for standard object operations. Proxies, being a relatively newer feature, may not benefit from the same level of optimization.

  4. Memory Usage: Proxies can increase memory usage due to the additional objects and closures created for the handler and traps.

  5. Benchmarks Show Slower Operations: Benchmarks across engines have shown that Proxies can be multiple times slower than regular object operations. This slowdown varies depending on the type of trap being used, the complexity of the handler logic, and the specific use case (e.g., simple property access vs. function invocation).

Mitigating Performance Concerns

  • Use Proxies Selectively: Only apply Proxies where necessary, rather than wrapping every object in a Proxy.

  • Minimize Complex Logic in Traps: Avoid performing heavy computations inside traps to reduce performance impact.

  • Combine Proxy and Traditional Object Patterns: For cases where performance is critical, you can mix Proxies with other methods like getters/setters or class-based models, using Proxies only when absolutely necessary.

  • Test for Bottlenecks: Always benchmark your code to identify where Proxies may be causing performance issues and evaluate whether the trade-offs are worth it.

Conclusion

In conclusion, the JavaScript Proxy object opens up a world of possibilities for developers by allowing fine-grained control over object behavior. From managing reactivity and caching to providing default values and implementing complex access patterns, Proxies enable cleaner, more efficient code with minimal efforts.

Much like a shape-shifter, the Proxy can seamlessly adapt to the ever-changing needs of your application, offering both flexibility and control without altering the core structure of your objects.

As you explore their capabilities, you may find that Proxies not only simplify your existing code but also inspire innovative solutions to common development challenges. Whether you're enhancing performance, enforcing validation, or simply making your code more expressive, the Proxy API is a powerful tool that can elevate your JavaScript projects to new heights. Embrace the flexibility and potential of this shape shifter, and you may discover new ways to think about and structure your applications.

Now that you've explored the versatility and power of the JavaScript Proxy API, it's time to put it into practice!

Try experimenting with Proxies in your own projects. Have an interesting use case or experience with Proxies? Share your thoughts and use cases in the comments—let’s learn together!

0
Subscribe to my newsletter

Read articles from Harpreet Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Harpreet Singh
Harpreet Singh

I am a developer from India. Interested in learning and sharing the knowledge.