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)"
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"
}
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"
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');
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"
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}
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:
- State Management — Frameworks like Vue.js and MobX use similar techniques for reactivity
- Validation — Ensuring data integrity throughout complex object hierarchies
- Performance Optimization — Memoization and lazy evaluation of expensive operations
- Debugging — Comprehensive tracking of property access and mutation
- 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)