Introduction
Proxy and Reflect are powerful metaprogramming features introduced in ES6 that allow you to intercept and customize fundamental operations on objects. A Proxy wraps an object and intercepts operations like property access, assignment, function calls, and more through handler functions called "traps." Reflect provides methods that correspond to every Proxy trap, giving you the default behavior for each intercepted operation.
Together, Proxy and Reflect enable sophisticated patterns like data validation, logging, reactive systems, and access control. Libraries like Vue 3 use Proxy for reactive state management, making these features foundational to modern JavaScript frameworks. This comprehensive guide covers Proxy traps, Reflect methods, and production-ready patterns used in enterprise applications.
Understanding Proxy
What is a Proxy?
A Proxy wraps a target object and intercepts operations through a handler object. The handler defines traps—methods that customize the behavior of specific operations. When you access a property on the Proxy, the engine calls your trap instead of performing the default operation.
The Proxy constructor takes two arguments: the target object to wrap and a handler object containing trap functions. Any operation performed on the Proxy is first intercepted by the corresponding trap in the handler. If the trap calls Reflect methods, the default behavior occurs. If the trap returns a different value, that value is used instead.
const target = { name: 'Alice', age: 30 };
const handler = {
get(target, property, receiver) {
console.log(`Accessing ${property}`);
return Reflect.get(target, property, receiver);
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.name);
// Logs: "Accessing name"
// Output: "Alice"Proxy Constructor and Revocable Proxies
// Basic proxy creation
const proxy = new Proxy(target, handler);
// Revocable proxy - can be disabled later
const { proxy: revocableProxy, revoke } = Proxy.revocable(target, handler);
console.log(revocableProxy.name); // 'Alice'
revoke(); // Disable the proxy
// revocableProxy.name; // TypeError: Cannot perform 'get' on a proxy that has been revoked
// Useful for temporary access control
function createLimitedAccess(obj, allowedKeys) {
const { proxy, revoke } = Proxy.revocable(obj, {
get(target, property) {
if (!allowedKeys.includes(property)) {
throw new Error(`Access denied to ${property}`);
}
return Reflect.get(target, property);
},
set() {
throw new Error('Read-only access');
}
});
return { proxy, revoke };
}Proxy Traps
get Trap
The get trap intercepts property read operations. It receives the target object, the property name, and the receiver (the proxy or an object inheriting from it):
const validationRules = {
age: { min: 0, max: 150 },
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
name: { minLength: 1, maxLength: 100 }
};
const validatedUser = new Proxy({}, {
get(target, property) {
if (!(property in target)) {
throw new Error(`Property ${property} does not exist`);
}
return target[property];
},
set(target, property, value) {
const rules = validationRules[property];
if (rules) {
if (rules.min !== undefined && value < rules.min) {
throw new Error(`${property} must be at least ${rules.min}`);
}
if (rules.max !== undefined && value > rules.max) {
throw new Error(`${property} must be at most ${rules.max}`);
}
if (rules.pattern && !rules.pattern.test(value)) {
throw new Error(`${property} has invalid format`);
}
if (rules.minLength !== undefined && value.length < rules.minLength) {
throw new Error(`${property} must be at least ${rules.minLength} characters`);
}
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
throw new Error(`${property} must be at most ${rules.maxLength} characters`);
}
}
target[property] = value;
return true;
}
});
validatedUser.age = 25; // Works
validatedUser.age = -5; // Error: age must be at least 0
validatedUser.email = 'invalid'; // Error: email has invalid formatset Trap
The set trap intercepts property write operations and must return a boolean indicating success:
const readOnlyObject = new Proxy({ secret: 'hidden', public: 'visible' }, {
set(target, property, value) {
if (property.startsWith('_') || property === 'secret') {
throw new Error(`Cannot modify property ${property}: object is read-only`);
}
return Reflect.set(target, property, value);
},
deleteProperty(target, property) {
throw new Error(`Cannot delete property ${property}: object is read-only`);
}
});
readOnlyObject.public = 'new value'; // Works
// readOnlyObject.secret = 'new'; // Error: Cannot modify property secrethas Trap
The has trap intercepts the in operator, allowing you to hide properties:
const hiddenProperties = new Proxy({ visible: 'yes', _hidden: 'secret', __private: 'data' }, {
has(target, property) {
if (property.startsWith('_')) {
return false; // Hide private properties
}
return property in target;
}
});
console.log('visible' in hiddenProperties); // true
console.log('_hidden' in hiddenProperties); // false
console.log('__private' in hiddenProperties); // falseapply Trap
The apply trap intercepts function calls, enabling logging, caching, and access control:
function sum(a, b) {
return a + b;
}
const loggedSum = new Proxy(sum, {
apply(target, thisArg, argumentsList) {
console.log(`Calling sum with args: ${argumentsList}`);
const start = performance.now();
const result = Reflect.apply(target, thisArg, argumentsList);
const end = performance.now();
console.log(`Result: ${result} (took ${(end - start).toFixed(2)}ms)`);
return result;
}
});
loggedSum(3, 4);
// Logs: "Calling sum with args: 3,4"
// Logs: "Result: 7 (took 0.05ms)"
// Memoization proxy
function memoize(fn) {
const cache = new Map();
return new Proxy(fn, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log('Cache hit');
return cache.get(key);
}
console.log('Cache miss');
const result = Reflect.apply(target, thisArg, args);
cache.set(key, result);
return result;
}
});
}
const memoizedSum = memoize(sum);
memoizedSum(3, 4); // Cache miss, result: 7
memoizedSum(3, 4); // Cache hit, result: 7construct Trap
The construct trap intercepts the new operator, enabling instance tracking and dependency injection:
class User {
constructor(name) {
this.name = name;
}
}
const LoggedUser = new Proxy(User, {
construct(target, argumentsList, newTarget) {
console.log(`Creating new User: ${argumentsList[0]}`);
const instance = Reflect.construct(target, argumentsList, newTarget);
instance.createdAt = new Date();
instance.id = Math.random().toString(36).substr(2, 9);
return instance;
}
});
const user = new LoggedUser('Alice');
// Logs: "Creating new User: Alice"
console.log(user.createdAt); // Current timestamp
console.log(user.id); // Random IDReflect API
Why Reflect?
Reflect provides default behavior for every Proxy trap. Using Reflect ensures correct handling of receivers, proper this binding, and consistent return values. It also provides a cleaner API than calling Object methods directly.
const handler = {
get(target, property, receiver) {
// Log access
console.log(`Get: ${String(property)}`);
// Use Reflect for default behavior
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
// Log modification
console.log(`Set: ${String(property)} = ${value}`);
// Use Reflect for default behavior
return Reflect.set(target, property, value, receiver);
}
};Reflect Methods
Reflect provides methods that correspond to every Proxy trap:
const obj = { x: 1, y: 2 };
// Property access
Reflect.get(obj, 'x'); // 1
Reflect.set(obj, 'z', 3); // true (obj.z = 3)
Reflect.has(obj, 'x'); // true (x in obj)
Reflect.deleteProperty(obj, 'z'); // true (delete obj.z)
// Object manipulation
Reflect.ownKeys(obj); // ['x', 'y']
Reflect.getOwnPropertyDescriptor(obj, 'x');
Reflect.defineProperty(obj, 'w', { value: 4, writable: true });
// Function operations
function greet(name) { return `Hello, ${name}`; }
Reflect.apply(greet, null, ['Alice']); // "Hello, Alice"
class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
Reflect.construct(Point, [1, 2]); // new Point(1, 2)Real-World Use Cases
Reactive Data System (Vue 3 Style)
function reactive(target) {
const proxy = new Proxy(target, {
get(target, property, receiver) {
track(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
const oldValue = target[property];
const result = Reflect.set(target, property, value, receiver);
if (oldValue !== value) {
trigger(target, property);
}
return result;
}
});
return proxy;
}
const targetMap = new WeakMap();
function track(target, property) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(property);
if (!deps) {
deps = new Set();
depsMap.set(property, deps);
}
deps.add(activeEffect);
}
function trigger(target, property) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(property);
if (deps) {
deps.forEach(effect => effect());
}
}
let activeEffect = null;
function watchEffect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}
// Usage
const state = reactive({ count: 0, message: 'Hello' });
watchEffect(() => {
console.log(`Count: ${state.count}`);
});
state.count++; // Logs: "Count: 1"
state.count++; // Logs: "Count: 2"API Response Normalization
function createApiProxy(baseUrl) {
return new Proxy({}, {
get(target, resource) {
return {
async list(params = {}) {
const query = new URLSearchParams(params);
const response = await fetch(`${baseUrl}/${resource}?${query}`);
return response.json();
},
async get(id) {
const response = await fetch(`${baseUrl}/${resource}/${id}`);
return response.json();
},
async create(data) {
const response = await fetch(`${baseUrl}/${resource}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
},
async update(id, data) {
const response = await fetch(`${baseUrl}/${resource}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
},
async delete(id) {
const response = await fetch(`${baseUrl}/${resource}/${id}`, {
method: 'DELETE'
});
return response.ok;
}
};
}
});
}
const api = createApiProxy('https://api.example.com');
// Dynamic resource access - no need to define each resource
const users = await api.users.list();
const user = await api.users.get(1);
const newUser = await api.users.create({ name: 'Alice' });Type Checking Proxy
function typed(target, schema) {
return new Proxy(target, {
set(target, property, value) {
const expectedType = schema[property];
if (!expectedType) {
throw new Error(`Unknown property: ${property}`);
}
if (typeof value !== expectedType) {
throw new TypeError(
`${property} must be ${expectedType}, got ${typeof value}`
);
}
return Reflect.set(target, property, value);
}
});
}
const user = typed({}, {
name: 'string',
age: 'number',
active: 'boolean'
});
user.name = 'Alice'; // Works
user.age = 30; // Works
user.age = 'thirty'; // TypeError: age must be number, got stringLogging Proxy
function withLogging(target, name = 'Object') {
return new Proxy(target, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value === 'function') {
return function(...args) {
console.log(`[${name}] ${String(property)}(${args.join(', ')})`);
const result = Reflect.apply(value, target, args);
console.log(`[${name}] => ${result}`);
return result;
};
}
console.log(`[${name}] get ${String(property)} => ${value}`);
return value;
},
set(target, property, value, receiver) {
console.log(`[${name}] set ${String(property)} = ${value}`);
return Reflect.set(target, property, value, receiver);
}
});
}
// Usage
const user = withLogging({ name: 'Alice', age: 30 }, 'User');
user.name; // Logs: "[User] get name => Alice"
user.age = 31; // Logs: "[User] set age = 31"Best Practices for Production
-
Always use Reflect: Use
Reflect.get/set/applyinside traps for correct behavior and proper receiver handling. -
Handle receivers properly: Pass
receiverto Reflect methods to maintain correctthisbinding, especially with inheritance. -
Return correct values: Traps must return the expected type (boolean for set, etc.) or the engine will throw errors.
-
Consider performance: Proxies add overhead to every intercepted operation. Don't use them in performance-critical hot paths.
-
Document proxy behavior: Proxies can be surprising to other developers. Document what your proxies intercept and why.
-
Use revocable proxies: When you need to disable access, use
Proxy.revocable()instead of tracking a boolean flag.
Performance Considerations
Proxies add overhead to every intercepted operation. Benchmarks show:
- Property access: 2-10x slower than direct access
- Function calls: 3-15x slower than direct calls
- Bulk operations: Significant impact in hot loops
Use proxies for:
- Development-time validation and logging
- API abstraction layers
- Reactive state management
- Access control patterns
Avoid proxies for:
- Hot loop iterations
- Performance-critical calculations
- High-frequency DOM operations
Proxies enable powerful metaprogramming patterns like validation, logging, and access control, but should be used judiciously due to their performance overhead.
Vue 3's Reactivity System: Proxy in Action
Vue 3's reactivity system is the most prominent real-world application of Proxy. Understanding how Vue uses Proxy helps you appreciate both the framework and the metaprogramming technique:
// Simplified version of Vue 3's reactive system
const targetMap = new WeakMap(); // target -> depsMap
const effectStack = [];
let activeEffect = null;
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let deps = depsMap.get(key);
if (!deps) depsMap.set(key, (deps = new Set()));
deps.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
if (deps) deps.forEach(effect => effect());
}
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key);
const result = Reflect.get(target, key, receiver);
// Deep reactivity: wrap nested objects
if (typeof result === 'object' && result !== null) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
trigger(target, key);
}
return result;
},
deleteProperty(target, key) {
const hadKey = key in target;
const result = Reflect.deleteProperty(target, key);
if (hadKey) trigger(target, key);
return result;
}
});
}
function effect(fn) {
activeEffect = fn;
fn(); // Run to collect dependencies
activeEffect = null;
}
// Usage
const state = reactive({ count: 0, user: { name: 'Alice' } });
effect(() => {
console.log('Count is:', state.count);
});
state.count++; // Triggers the effect, logs "Count is: 1"
state.user.name = 'Bob'; // Triggers effects watching user.nameVue 3's reactive system uses WeakMap for automatic garbage collection—when the target object is garbage collected, its dependency map is also collected. This prevents memory leaks that plagued Vue 2's Object.defineProperty-based system.
Comparison: Proxy vs Object.defineProperty
| Feature | Proxy (Vue 3) | Object.defineProperty (Vue 2) |
|---|---|---|
| Array index detection | ✅ Automatic | ❌ Requires override |
| Property addition | ✅ Automatic | ❌ Requires $set |
| Property deletion | ✅ Automatic | ❌ Requires $delete |
| Nested object tracking | ✅ Lazy (on access) | ❌ Eager (on init) |
| Performance | Better for large objects | Better for small objects |
| Browser support | ES6+ (all modern) | IE9+ |
Advanced Patterns: Computed Properties with Proxy
function computed(getter) {
let cachedValue;
let dirty = true;
const effect = new Proxy({ value: undefined }, {
get(target, key) {
if (key === 'value') {
if (dirty) {
cachedValue = getter();
dirty = false;
}
return cachedValue;
}
}
});
// Mark as dirty when dependencies change
// (In real implementation, this connects to the reactive system)
return effect;
}
// Usage
const state = reactive({ firstName: 'John', lastName: 'Doe' });
const fullName = computed(() => `${state.firstName} ${state.lastName}`);
console.log(fullName.value); // "John Doe"Real-World Pattern: API Client with Proxy
function createApiClient(baseUrl, options = {}) {
const cache = new Map();
const { cacheTimeout = 60000, headers = {} } = options;
return new Proxy({}, {
get(target, resource) {
if (target[resource]) return target[resource];
target[resource] = new Proxy({}, {
get(_, action) {
return async (...args) => {
const cacheKey = `${resource}:${action}:${JSON.stringify(args)}`;
// Check cache
if (cache.has(cacheKey)) {
const { data, timestamp } = cache.get(cacheKey);
if (Date.now() - timestamp < cacheTimeout) return data;
}
const url = `${baseUrl}/${resource}`;
let response;
switch (action) {
case 'list':
response = await fetch(url, { headers });
break;
case 'get':
response = await fetch(`${url}/${args[0]}`, { headers });
break;
case 'create':
response = await fetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(args[0])
});
break;
case 'update':
response = await fetch(`${url}/${args[0]}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(args[1])
});
break;
case 'delete':
response = await fetch(`${url}/${args[0]}`, { method: 'DELETE' });
return response.ok;
}
const data = await response.json();
cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
};
}
});
return target[resource];
}
});
}
const api = createApiClient('https://api.example.com', { cacheTimeout: 30000 });
const users = await api.users.list();
const user = await api.users.get(1);
await api.users.update(1, { name: 'Updated' });Security: Proxy for Access Control
function createSecureObject(target, permissions) {
return new Proxy(target, {
get(target, property, receiver) {
if (property === 'toJSON' || property === 'toString') {
return Reflect.get(target, property, receiver);
}
const perm = permissions[property];
if (!perm) {
throw new Error(`Access denied: property '${property}' does not exist`);
}
if (!perm.read) {
throw new Error(`Access denied: no read permission for '${property}'`);
}
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
const perm = permissions[property];
if (!perm || !perm.write) {
throw new Error(`Access denied: no write permission for '${property}'`);
}
return Reflect.set(target, property, value, receiver);
},
has(target, property) {
return property in permissions && permissions[property].read;
},
ownKeys(target) {
return Object.keys(permissions).filter(k => permissions[k].read);
}
});
}
const sensitiveData = createSecureObject(
{ username: 'alice', password: 'secret123', email: 'alice@example.com' },
{
username: { read: true, write: false },
password: { read: false, write: false },
email: { read: true, write: true }
}
);
console.log(sensitiveData.username); // "alice"
console.log(sensitiveData.password); // Error: Access denied
sensitiveData.email = 'new@example.com'; // WorksDebugging Proxies
Proxies can be difficult to debug because they intercept operations invisibly. Here are techniques for debugging proxy-based code:
// Debug proxy: logs all intercepted operations
function debugProxy(target, name = 'Target') {
return new Proxy(target, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
console.log(`[${name}] GET ${String(prop)} =>`, typeof value === 'function' ? 'function' : value);
return value;
},
set(target, prop, value, receiver) {
console.log(`[${name}] SET ${String(prop)} =`, value);
return Reflect.set(target, prop, value, receiver);
},
apply(target, thisArg, args) {
console.log(`[${name}] CALL with`, args);
return Reflect.apply(target, thisArg, args);
},
has(target, prop) {
console.log(`[${name}] HAS ${String(prop)}`);
return Reflect.has(target, prop);
}
});
}
// Usage during development
const state = debugProxy(reactive({ count: 0 }), 'State');
state.count++; // Logs: [State] GET count => 0, [State] SET count = 1Conclusion
Proxy and Reflect are essential JavaScript metaprogramming tools. They enable patterns like reactive systems, validation layers, and API abstractions that form the foundation of modern frameworks like Vue 3.
Understanding these features provides insight into how modern frameworks implement reactivity and data binding. The patterns demonstrated here are simplified versions of what production frameworks use, but they illustrate the core concepts that make reactive programming possible in JavaScript.
Key takeaways:
- Proxy intercepts fundamental operations on objects through traps
- Reflect provides default behavior for every trap
- Always use Reflect inside traps for correct behavior
- Proxies power reactive systems like Vue 3
- Consider performance implications in hot paths
- Use revocable proxies for temporary access control
Master Proxy and Reflect to build sophisticated JavaScript abstractions and understand how modern frameworks work under the hood. These features are essential for building reactive data systems, validation layers, and API abstractions in production applications.