DEV Community

Cover image for 3. Proxy-geddon: Advanced JavaScript Proxy Techniques That Will Blow Your Mind
Sandheep Kumar Patro
Sandheep Kumar Patro

Posted on

3. Proxy-geddon: Advanced JavaScript Proxy Techniques That Will Blow Your Mind

Advanced Proxies: Maps, Sets, Functions, and Deep Proxies

In our previous articles, we explored the fundamentals of JavaScript Proxies and their application to objects and arrays. Now, let's elevate our understanding by examining how Proxies can enhance more complex data structures and functions. This is where Proxies truly shine, enabling sophisticated patterns that would be challenging or impossible with traditional approaches.

Proxies with Maps and Sets

Maps and Sets are powerful built-in collections introduced in ES6, and Proxies can intercept their methods to add custom behavior.

Tracking Map Operations

Maps support a variety of methods: set, get, has, delete, and more. We can proxy these operations to track usage patterns or debug complex code:

function createTrackedMap(initialEntries = []) {
  const map = new Map(initialEntries);

  return new Proxy(map, {
    get(target, property, receiver) {
      const value = target[property];

      if (typeof value === 'function') {
        return function(...args) {
          const methodName = property.toString();

          switch (methodName) {
            case 'set':
              console.log(`Map.set(${args[0]}, ${JSON.stringify(args[1])})`);
              break;
            case 'get':
              console.log(`Map.get(${args[0]})`);
              break;
            case 'has':
              console.log(`Map.has(${args[0]})`);
              break;
            case 'delete':
              console.log(`Map.delete(${args[0]})`);
              break;
          }

          return value.apply(target, args);
        };
      }

      return value;
    }
  });
}

const userPreferences = createTrackedMap([
  ['theme', 'dark'],
  ['fontSize', 16]
]);

userPreferences.set('language', 'en');  // "Map.set(language, "en")"
console.log(userPreferences.get('theme'));  // "Map.get(theme)", "dark"
userPreferences.delete('fontSize');  // "Map.delete(fontSize)"
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly valuable when debugging complex applications or implementing caching mechanisms where you need to track key access patterns.

Enhancing Sets with Validation

We can extend Set behavior by adding validation rules for new elements:

function createValidatedSet(validator) {
  const set = new Set();

  return new Proxy(set, {
    get(target, property, receiver) {
      const value = target[property];

      if (property === 'add') {
        return function(item) {
          if (!validator(item)) {
            throw new Error('Invalid item for Set');
          }
          return value.call(target, item);
        };
      }

      if (typeof value === 'function') {
        return function(...args) {
          return value.apply(target, args);
        };
      }

      return value;
    }
  });
}

// Create a Set that only accepts numbers
const numberSet = createValidatedSet(item => typeof item === 'number');

numberSet.add(42);  // Works fine
numberSet.add(100);  // Works fine
try {
  numberSet.add("not a number");
} catch (e) {
  console.error(e.message);  // "Invalid item for Set"
}
Enter fullscreen mode Exit fullscreen mode

This creates type-safe collections that maintain data integrity, reducing bugs and improving code quality.

Function Proxies

Functions in JavaScript are first-class objects, which means we can proxy them too. This opens up possibilities for function timing, memoization, and parameter validation.

Logging Function Calls

We can wrap functions to log their inputs and outputs:

function createLoggedFunction(fn, name = fn.name) {
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      console.log(`Calling ${name} with arguments:`, args);

      const start = performance.now();
      const result = target.apply(thisArg, args);
      const end = performance.now();

      console.log(`${name} returned:`, result);
      console.log(`Execution time: ${(end - start).toFixed(2)}ms`);

      return result;
    }
  });
}

function calculateTotal(items, tax = 0.1) {
  return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
}

const loggedCalculateTotal = createLoggedFunction(calculateTotal, 'calculateTotal');

const cart = [
  { name: 'Laptop', price: 1000 },
  { name: 'Mouse', price: 25 },
  { name: 'Keyboard', price: 85 }
];

loggedCalculateTotal(cart);
// Logs:
// "Calling calculateTotal with arguments: [[{name:'Laptop',price:1000},...], 0.1]"
// "calculateTotal returned: 1221"
// "Execution time: 0.12ms"
Enter fullscreen mode Exit fullscreen mode

This pattern is invaluable for debugging, performance analysis, and detailed logging in production systems.

Auto-Memoization (Caching Results)

Memoization caches function results based on input parameters, avoiding expensive recalculations. Proxies make this easy to implement:

function memoize(fn) {
  const cache = new Map();

  return new Proxy(fn, {
    apply(target, thisArg, args) {
      // Create a cache key from the arguments
      const key = JSON.stringify(args);

      if (cache.has(key)) {
        console.log(`Cache hit for ${key}`);
        return cache.get(key);
      }

      console.log(`Cache miss for ${key}, calculating...`);
      const result = target.apply(thisArg, args);
      cache.set(key, result);

      return result;
    }
  });
}

// Fibonacci function (intentionally inefficient for demonstration)
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const memoizedFib = memoize(fibonacci);

console.time('First call');
console.log(memoizedFib(30));  // Slow, needs to calculate
console.timeEnd('First call');

console.time('Second call');
console.log(memoizedFib(30));  // Fast, returns cached result
console.timeEnd('Second call');
Enter fullscreen mode Exit fullscreen mode

For functions with expensive calculations or API calls, this pattern can dramatically improve performance.

Deep Proxies (Nested Objects & Recursive Wrapping)

One challenge with standard proxies is that they only intercept operations on the immediate object, not on nested objects. Deep proxies solve this by recursively wrapping nested properties.

Implementing a Deep Proxy

Here's a pattern for creating deep proxies that track changes at any level of nesting:

function createDeepProxy(target, handler) {
  // If the target isn't an object, we can't proxy it
  if (typeof target !== 'object' || target === null) {
    return target;
  }

  // Create proxies for all nested objects
  for (const key in target) {
    if (typeof target[key] === 'object' && target[key] !== null) {
      target[key] = createDeepProxy(target[key], handler);
    }
  }

  // Special handling for getting nested properties
  const originalGetHandler = handler.get || ((target, property) => target[property]);
  handler.get = function(target, property, receiver) {
    const value = originalGetHandler(target, property, receiver);

    // If we access an object property, ensure it's proxied
    if (typeof value === 'object' && value !== null) {
      return createDeepProxy(value, handler);
    }

    return value;
  };

  // Create the proxy for this level
  return new Proxy(target, handler);
}

// Usage example - tracking all property access at any nesting level
const userData = {
  name: "Jordan",
  address: {
    street: "123 Main St",
    city: "Techville",
    location: {
      latitude: 37.7749,
      longitude: -122.4194
    }
  },
  preferences: {
    theme: "dark",
    notifications: {
      email: true,
      push: false
    }
  }
};

const trackedUserData = createDeepProxy(userData, {
  get(target, property, receiver) {
    const value = target[property];
    console.log(`Accessing: ${property}`);
    return value;
  },

  set(target, property, value, receiver) {
    console.log(`Setting ${property} to:`, value);
    target[property] = value;
    return true;
  }
});

// These will all be tracked
console.log(trackedUserData.name);                        // "Accessing: name", "Jordan"
console.log(trackedUserData.address.city);                // "Accessing: address", "Accessing: city", "Techville"
trackedUserData.preferences.notifications.push = true;    // "Accessing: preferences", "Accessing: notifications", "Setting push to: true"
Enter fullscreen mode Exit fullscreen mode

Tracking Deeply Nested State Changes

This pattern is the foundation of state management systems in modern frontend frameworks. For example, we can implement a simple reactive store:

function createStore(initialState = {}) {
  let listeners = [];

  // Create a path string from an array of properties
  function getPath(path) {
    return path.join('.');
  }

  const handler = {
    get(target, property, receiver) {
      if (property === '__addListener') {
        return function(listener) {
          listeners.push(listener);
          return function() {
            listeners = listeners.filter(l => l !== listener);
          };
        };
      }

      // Track the path of accessed properties
      const value = target[property];

      if (typeof value === 'object' && value !== null) {
        return createDeepProxy(value, handler, [property]);
      }

      return value;
    },

    set(target, property, value, receiver) {
      const oldValue = target[property];

      if (oldValue !== value) {
        target[property] = value;

        // Notify all listeners of the change
        listeners.forEach(listener => {
          listener({
            property,
            oldValue,
            newValue: value
          });
        });
      }

      return true;
    }
  };

  return createDeepProxy(initialState, handler);
}

// Simple usage example
const store = createStore({
  user: {
    name: "Casey",
    isLoggedIn: true
  },
  cart: {
    items: [],
    total: 0
  }
});

// Subscribe to changes
const unsubscribe = store.__addListener(change => {
  console.log('State changed:', change);
});

// These changes will trigger the listener
store.user.name = "Riley";         // State changed: {property: "name", oldValue: "Casey", newValue: "Riley"}
store.cart.items.push({            // Array methods would need special handling in a real implementation
  id: 1, 
  name: "Product", 
  price: 29.99
});
store.cart.total = 29.99;          // State changed: {property: "total", oldValue: 0, newValue: 29.99}
Enter fullscreen mode Exit fullscreen mode

This is a simplified example—real implementations like Vue or MobX have more sophisticated tracking mechanisms, but the core concept is the same.

Real-World Implications

These advanced proxy patterns enable a wide range of powerful applications:

  1. State Management — Frameworks like Vue.js and MobX use similar techniques for reactivity
  2. Validation — Ensuring data integrity throughout complex object hierarchies
  3. Performance Optimization — Memoization and lazy evaluation of expensive operations
  4. Debugging — Comprehensive tracking of property access and mutation
  5. API Design — Creating intuitive interfaces that handle complexity behind the scenes

By understanding these patterns, you can build more robust applications with cleaner code and fewer bugs.

Top comments (0)