JavaScript Proxies: Metaprogramming Guide
JavaScript Proxies are a metaprogramming feature that lets you intercept and customize fundamental operations on objects. Instead of directly accessing an object's properties or methods, you can wrap...
Key Insights
- Proxies intercept fundamental object operations like property access, assignment, and function calls, enabling powerful metaprogramming patterns that would otherwise require verbose boilerplate code
- Modern reactive frameworks like Vue 3 rely heavily on Proxies to automatically track dependencies and trigger updates, making them essential for understanding contemporary JavaScript architecture
- While Proxies offer elegant solutions for validation, logging, and API design, they introduce performance overhead that requires careful consideration in hot code paths
Introduction to JavaScript Proxies
JavaScript Proxies are a metaprogramming feature that lets you intercept and customize fundamental operations on objects. Instead of directly accessing an object’s properties or methods, you can wrap it in a Proxy that defines custom behavior for reads, writes, deletions, and more.
The Proxy API consists of two parts: the target object and a handler containing traps. Traps are methods that intercept specific operations. When you interact with a proxied object, the corresponding trap executes before (or instead of) the default behavior.
const target = { name: 'Alice', age: 30 };
const handler = {
get(target, property) {
console.log(`Reading property: ${property}`);
return target[property];
},
set(target, property, value) {
console.log(`Setting ${property} to ${value}`);
target[property] = value;
return true; // Indicates success
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.name); // Logs: "Reading property: name", then "Alice"
proxy.age = 31; // Logs: "Setting age to 31"
This simple example demonstrates the core concept: your handler intercepts operations and can add logic before delegating to the target.
Common Proxy Traps and Use Cases
JavaScript provides 13 different traps, but you’ll use a handful most frequently. The get and set traps handle property access and assignment. The has trap intercepts the in operator, while deleteProperty catches deletion attempts. The apply and construct traps work with functions and constructors.
Here’s a validation proxy that enforces type constraints:
function createTypedObject(schema) {
const data = {};
return new Proxy(data, {
set(target, property, value) {
const expectedType = schema[property];
if (!expectedType) {
throw new Error(`Property ${property} not defined in schema`);
}
if (typeof value !== expectedType) {
throw new TypeError(
`${property} must be ${expectedType}, got ${typeof value}`
);
}
target[property] = value;
return true;
}
});
}
const user = createTypedObject({
name: 'string',
age: 'number',
active: 'boolean'
});
user.name = 'Bob'; // Works
user.age = 25; // Works
user.age = '25'; // Throws TypeError
user.email = 'test'; // Throws Error (not in schema)
A logging proxy provides visibility into object interactions, invaluable for debugging:
function createLoggingProxy(target, label = 'Object') {
return new Proxy(target, {
get(target, property) {
const value = target[property];
console.log(`[${label}] GET ${String(property)} => ${value}`);
return value;
},
set(target, property, value) {
console.log(`[${label}] SET ${String(property)} = ${value}`);
target[property] = value;
return true;
},
deleteProperty(target, property) {
console.log(`[${label}] DELETE ${String(property)}`);
delete target[property];
return true;
}
});
}
const config = createLoggingProxy({ theme: 'dark' }, 'Config');
config.theme; // Logs: [Config] GET theme => dark
config.language = 'en'; // Logs: [Config] SET language = en
Real-World Application: Data Binding and Reactivity
Proxies are the foundation of reactivity in modern frameworks. When data changes, the UI updates automatically. Here’s a minimal reactive system:
class ReactiveState {
constructor(initialState) {
this.listeners = new Set();
this.state = new Proxy(initialState, {
set: (target, property, value) => {
const oldValue = target[property];
target[property] = value;
if (oldValue !== value) {
this.notify(property, value, oldValue);
}
return true;
}
});
}
notify(property, newValue, oldValue) {
this.listeners.forEach(listener => {
listener(property, newValue, oldValue);
});
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
// Usage
const appState = new ReactiveState({ count: 0, message: 'Hello' });
appState.subscribe((prop, newVal) => {
console.log(`${prop} changed to ${newVal}`);
document.getElementById(prop).textContent = newVal;
});
appState.state.count = 1; // Auto-updates DOM
appState.state.message = 'Hi'; // Auto-updates DOM
You can extend this pattern to support computed properties:
function createComputed(state, computeFn) {
let cache;
let dirty = true;
const proxy = new Proxy(state, {
get(target, property) {
dirty = true; // Invalidate cache on any access
return target[property];
}
});
return () => {
if (dirty) {
cache = computeFn(proxy);
dirty = false;
}
return cache;
};
}
const state = { firstName: 'John', lastName: 'Doe' };
const fullName = createComputed(state, (s) => `${s.firstName} ${s.lastName}`);
console.log(fullName()); // "John Doe"
state.firstName = 'Jane';
console.log(fullName()); // "Jane Doe"
Advanced Patterns: Negative Array Indexing and Default Values
Python developers enjoy negative array indexing where arr[-1] returns the last element. Proxies make this trivial in JavaScript:
function createSmartArray(arr) {
return new Proxy(arr, {
get(target, property) {
const index = Number(property);
if (Number.isInteger(index)) {
if (index < 0) {
return target[target.length + index];
}
}
return target[property];
}
});
}
const arr = createSmartArray([10, 20, 30, 40, 50]);
console.log(arr[-1]); // 50
console.log(arr[-2]); // 40
console.log(arr[0]); // 10
Another useful pattern is automatic default values for missing properties:
function createDefaultDict(defaultFactory) {
return new Proxy({}, {
get(target, property) {
if (!(property in target)) {
target[property] = defaultFactory();
}
return target[property];
}
});
}
const wordCount = createDefaultDict(() => 0);
const words = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple'];
words.forEach(word => {
wordCount[word] += 1; // No need to check if property exists
});
console.log(wordCount); // { apple: 3, banana: 2, cherry: 1 }
Performance Considerations and Gotchas
Proxies add overhead. Every intercepted operation has a cost. In tight loops or hot paths, this matters:
const obj = { value: 0 };
const proxied = new Proxy(obj, {
get: (target, prop) => target[prop],
set: (target, prop, val) => (target[prop] = val, true)
});
// Direct access benchmark
console.time('direct');
for (let i = 0; i < 1000000; i++) {
obj.value = i;
const x = obj.value;
}
console.timeEnd('direct'); // ~10ms
// Proxied access benchmark
console.time('proxied');
for (let i = 0; i < 1000000; i++) {
proxied.value = i;
const x = proxied.value;
}
console.timeEnd('proxied'); // ~100ms (10x slower)
Revocable proxies provide a way to disable access, useful for security or resource management:
const { proxy, revoke } = Proxy.revocable(
{ secret: 'password123' },
{
get(target, prop) {
console.log(`Accessing ${prop}`);
return target[prop];
}
}
);
console.log(proxy.secret); // Works: "password123"
revoke(); // Disable the proxy
console.log(proxy.secret); // Throws TypeError: Cannot perform 'get' on a proxy that has been revoked
Be aware that Proxies don’t trap internal slots. You can’t proxy Date, Map, or Set objects transparently because their methods rely on internal state that Proxies can’t intercept.
Practical Applications in Production Code
Proxies excel at cross-cutting concerns. Here’s an API client with automatic logging:
function createAPIClient(baseURL) {
const client = {
async get(endpoint) {
const response = await fetch(`${baseURL}${endpoint}`);
return response.json();
},
async post(endpoint, data) {
const response = await fetch(`${baseURL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
};
return new Proxy(client, {
get(target, property) {
const original = target[property];
if (typeof original !== 'function') return original;
return async function(...args) {
console.log(`[API] ${property.toUpperCase()} ${args[0]}`);
const start = Date.now();
try {
const result = await original.apply(this, args);
console.log(`[API] ${property.toUpperCase()} completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`[API] ${property.toUpperCase()} failed:`, error);
throw error;
}
};
}
});
}
const api = createAPIClient('https://api.example.com');
await api.get('/users'); // Logs request and timing automatically
Property deprecation warnings help migrate codebases:
function deprecate(obj, deprecatedProps) {
return new Proxy(obj, {
get(target, property) {
if (property in deprecatedProps) {
const { replacement, version } = deprecatedProps[property];
console.warn(
`Warning: ${property} is deprecated since v${version}. ` +
`Use ${replacement} instead.`
);
}
return target[property];
}
});
}
const math = deprecate(
{ add: (a, b) => a + b, sum: (a, b) => a + b },
{ add: { replacement: 'sum', version: '2.0' } }
);
math.add(1, 2); // Warns about deprecation, but still works
Conclusion and Best Practices
Use Proxies when you need to intercept operations uniformly across an object. They’re ideal for validation, logging, reactivity, and API design. Avoid them in performance-critical code unless profiling shows the overhead is acceptable.
Keep handlers focused. Each trap should have a single responsibility. Document proxied behavior clearly—future maintainers need to understand the magic. Consider alternatives like getters/setters for simple cases or decorators for class-based patterns.
Proxies are powerful but not a silver bullet. They shine when the alternative is repetitive boilerplate or when building framework-level abstractions. In application code, reach for simpler solutions first, but keep Proxies in your toolkit for problems they solve elegantly.