JavaScript Proxy and Reflect
JavaScript's Proxy and Reflect APIs are among the most powerful — and underutilized — features introduced in ES2015. They open the door to true metaprogramming: the ability to intercept and redefine fundamental object operations like property access, assignment, function calls, and more. Vue 3's reactivity engine is built on Proxy. MobX, Immer, and numerous validation libraries leverage these APIs under the hood. Understanding them will fundamentally change how you think about objects in JavaScript.
Vue 3 switched from Object.defineProperty() to Proxy for its reactivity system in 2020 — enabling it to detect new property additions and array index mutations that Vue 2 couldn't track. Understanding Proxy means understanding how modern reactive frameworks work at their core.
What is a Proxy?
A Proxy wraps a target object and intercepts operations performed on it through traps — handler functions that execute whenever those operations are triggered. Think of it as middleware for JavaScript objects: every get, set, delete, and function call passes through your custom handler before (or instead of) reaching the underlying object.
The basic syntax is straightforward:
const target = { name: "Alice", age: 30 };
const handler = {
get(target, property, receiver) {
console.log(`Getting property: ${property}`);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log(`Setting ${property} = ${value}`);
return Reflect.set(target, property, value, receiver);
}
};
const proxy = new Proxy(target, handler);
proxy.name; // Logs: "Getting property: name" → "Alice"
proxy.age = 31; // Logs: "Setting age = 31"
proxy.age; // Logs: "Getting property: age" → 31
The three key participants are:
- target — The original object being wrapped
- handler — An object containing trap functions
- proxy — The new object that consumers interact with
Operations flow through the Proxy handler before reaching the target object
Essential Proxy Traps
There are 13 possible traps corresponding to JavaScript's internal object operations. Here are the ones you'll use most often:
| Trap | Triggered By | Return Type |
|---|---|---|
get(target, prop, receiver) |
proxy.prop, proxy['prop'] |
Any value |
set(target, prop, value, receiver) |
proxy.prop = value |
boolean |
has(target, prop) |
prop in proxy |
boolean |
deleteProperty(target, prop) |
delete proxy.prop |
boolean |
apply(target, thisArg, args) |
proxy(), proxy.call() |
Any value |
construct(target, args) |
new proxy() |
Object |
ownKeys(target) |
Object.keys(), for...in |
Array |
defineProperty(target, prop, desc) |
Object.defineProperty() |
boolean |
The get Trap: Read Interception
The get trap is the most commonly used. A powerful pattern is returning default values for missing properties — great for building configuration objects or safe navigation:
// Safe property access — returns null instead of undefined
function safeObject(obj) {
return new Proxy(obj, {
get(target, prop) {
return prop in target ? target[prop] : null;
}
});
}
const config = safeObject({ theme: "dark", language: "en" });
console.log(config.theme); // "dark"
console.log(config.missing); // null (not undefined)
// Virtual properties derived from existing data
const user = new Proxy({ firstName: "John", lastName: "Doe" }, {
get(target, prop) {
if (prop === "fullName") {
return `${target.firstName} ${target.lastName}`;
}
return Reflect.get(target, prop);
}
});
console.log(user.fullName); // "John Doe" (property doesn't exist on target)
console.log(user.firstName); // "John"
The set Trap: Write Validation
The set trap must return true for success or false for failure (which triggers a TypeError in strict mode). This is perfect for runtime type validation:
function createTypedObject(schema) {
return new Proxy({}, {
set(target, prop, value) {
if (!(prop in schema)) {
throw new TypeError(`Unknown property: ${prop}`);
}
const expectedType = schema[prop];
if (typeof value !== expectedType) {
throw new TypeError(
`Property "${prop}" must be of type ${expectedType}, got ${typeof value}`
);
}
target[prop] = value;
return true; // Required: must return true on success
}
});
}
const person = createTypedObject({ name: "string", age: "number" });
person.name = "Alice"; // OK
person.age = 30; // OK
person.age = "thirty"; // TypeError: Property "age" must be of type number
person.score = 100; // TypeError: Unknown property: score
If your set trap doesn't return true (or returns a falsy value), it will throw a TypeError: 'set' on proxy: trap returned falsish for property in strict mode. Always explicitly return true after a successful set, or use return Reflect.set(target, prop, value, receiver).
The apply Trap: Function Interception
You can proxy functions, not just objects. The apply trap fires on any function invocation:
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 for: ${key}`);
return cache.get(key);
}
const result = Reflect.apply(target, thisArg, args);
cache.set(key, result);
return result;
}
});
}
function slowFibonacci(n) {
if (n <= 1) return n;
return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}
const fastFibonacci = memoize(slowFibonacci);
fastFibonacci(10); // Computed
fastFibonacci(10); // Cache hit! Returns immediately
fastFibonacci(20); // Computed
The Reflect API
The Reflect object provides static methods that correspond to every Proxy trap. It's the "do the default thing" companion to Proxy. Every Reflect method mirrors a Proxy trap's signature exactly, which makes them natural pairs.
Why use Reflect instead of directly operating on the target?
- Correct return values:
Reflect.set()returnstrue/false, whereas direct assignment on the target throws in strict mode - Receiver propagation:
Reflect.get(target, prop, receiver)correctly handles getters that usethis— critical for prototype chains - Consistency: Reflect methods behave exactly like the underlying spec operations, avoiding subtle edge cases
- Symmetry: Same parameter signature as the corresponding Proxy trap, making handlers predictable
const base = {
get value() {
return this._value;
}
};
const child = Object.create(base);
child._value = 42;
// WRONG: target[prop] loses the correct receiver
const badProxy = new Proxy(child, {
get(target, prop) {
return target[prop]; // `this` inside getter = target, not proxy
}
});
// CORRECT: Reflect.get passes receiver through the prototype chain
const goodProxy = new Proxy(child, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver); // `this` = proxy (receiver)
}
});
// With a prototype chain and getter, badProxy would break.
// goodProxy always works correctly.
// Reflect.get(target, propertyKey[, receiver])
// Equivalent to: target[propertyKey]
const obj = { x: 1 };
Reflect.get(obj, 'x'); // 1
Reflect.get([1, 2], 0); // 1
// With receiver — important for getters
const parent = {
get greeting() { return `Hello from ${this.name}`; }
};
const child = Object.create(parent);
child.name = "child";
Reflect.get(parent, 'greeting', child); // "Hello from child"
// parent[prop] would return "Hello from undefined"
// Reflect.set(target, propertyKey, value[, receiver])
// Equivalent to: target[propertyKey] = value
// Returns: true on success, false on failure (no throws!)
const obj = {};
const success = Reflect.set(obj, 'name', 'Alice');
console.log(success); // true
console.log(obj.name); // "Alice"
// Sealed/frozen object
const frozen = Object.freeze({ x: 1 });
const result = Reflect.set(frozen, 'x', 2);
console.log(result); // false (no TypeError!)
// Direct assignment in strict mode: TypeError
// frozen.x = 2; // Would throw
// Reflect.has(target, propertyKey)
// Equivalent to: prop in target
Reflect.has({ x: 1 }, 'x'); // true
Reflect.has({ x: 1 }, 'y'); // false
Reflect.has([1, 2, 3], '0'); // true
// Reflect.deleteProperty(target, propertyKey)
// Equivalent to: delete target[prop]
// Returns: true on success, false on failure
const obj = { a: 1, b: 2 };
Reflect.deleteProperty(obj, 'a'); // true
console.log(obj); // { b: 2 }
const locked = Object.freeze({ x: 1 });
Reflect.deleteProperty(locked, 'x'); // false (no TypeError!)
// delete locked.x; // Would throw in strict mode
// Reflect.apply(target, thisArgument, argumentsList)
// Equivalent to: Function.prototype.apply.call(target, thisArg, args)
function sum(...nums) { return nums.reduce((a, b) => a + b, 0); }
Reflect.apply(sum, null, [1, 2, 3, 4]); // 10
// Safer than Function.prototype.apply — works even if target's
// apply method has been overridden
// Reflect.construct(target, argumentsList[, newTarget])
// Equivalent to: new target(...args)
class Point { constructor(x, y) { this.x = x; this.y = y; } }
const p = Reflect.construct(Point, [3, 4]);
console.log(p instanceof Point); // true
console.log(p.x, p.y); // 3, 4
Real-World Use Cases
1. Building a Reactive Data System
This is the most impactful real-world application of Proxy. Vue 3's reactivity engine is conceptually similar to the following pattern:
const subscribers = new Map(); // property → Set of callbacks
let activeEffect = null;
function reactive(target) {
return new Proxy(target, {
get(target, prop, receiver) {
// Track: this property is being accessed during an effect
if (activeEffect) {
if (!subscribers.has(prop)) {
subscribers.set(prop, new Set());
}
subscribers.get(prop).add(activeEffect);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
const result = Reflect.set(target, prop, value, receiver);
// Trigger: notify all subscribers when property changes
if (subscribers.has(prop)) {
subscribers.get(prop).forEach(effect => effect());
}
return result;
}
});
}
function effect(fn) {
activeEffect = fn;
fn(); // Run immediately to collect dependencies
activeEffect = null;
}
// Usage
const state = reactive({ count: 0, message: "Hello" });
effect(() => {
console.log(`Count is: ${state.count}`);
});
// Logs: "Count is: 0"
state.count++; // Logs: "Count is: 1" (automatically!)
state.count++; // Logs: "Count is: 2"
state.message = "World"; // Nothing logged (no subscriber for message)
2. Input Validation with Schema
function createValidator(target, schema) {
return new Proxy(target, {
set(target, prop, value) {
const rule = schema[prop];
if (!rule) {
throw new Error(`Property "${prop}" is not in schema`);
}
if (rule.type && typeof value !== rule.type) {
throw new TypeError(
`"${prop}" expects ${rule.type}, got ${typeof value}`
);
}
if (rule.min !== undefined && value < rule.min) {
throw new RangeError(
`"${prop}" must be >= ${rule.min}`
);
}
if (rule.max !== undefined && value > rule.max) {
throw new RangeError(
`"${prop}" must be <= ${rule.max}`
);
}
if (rule.pattern && !rule.pattern.test(value)) {
throw new Error(`"${prop}" doesn't match required pattern`);
}
return Reflect.set(target, prop, value);
}
});
}
const userSchema = {
name: { type: "string", pattern: /^[a-zA-Z\s]+$/ },
age: { type: "number", min: 0, max: 150 },
email: { type: "string", pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
};
const user = createValidator({}, userSchema);
user.name = "Alice"; // OK
user.age = 25; // OK
user.email = "alice@example.com"; // OK
user.age = -5; // RangeError: "age" must be >= 0
user.name = "Alice123"; // Error: doesn't match required pattern
3. Logging and Debugging Proxy
function withAuditLog(target, name = "Object") {
const log = [];
const proxy = new Proxy(target, {
get(target, prop, receiver) {
if (prop === "__auditLog") return log;
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
const oldValue = Reflect.get(target, prop, receiver);
log.push({
type: "SET",
property: prop,
oldValue,
newValue: value,
timestamp: new Date().toISOString()
});
return Reflect.set(target, prop, value, receiver);
},
deleteProperty(target, prop) {
const oldValue = Reflect.get(target, prop);
log.push({
type: "DELETE",
property: prop,
oldValue,
timestamp: new Date().toISOString()
});
return Reflect.deleteProperty(target, prop);
}
});
return proxy;
}
const cart = withAuditLog({ items: [], total: 0 }, "Cart");
cart.items = ["apple", "banana"];
cart.total = 3.50;
delete cart.total;
console.log(cart.__auditLog);
// [
// { type: "SET", property: "items", oldValue: [], newValue: [...] },
// { type: "SET", property: "total", oldValue: 0, newValue: 3.5 },
// { type: "DELETE", property: "total", oldValue: 3.5 }
// ]
4. Private Properties with has Trap
function withPrivateProps(target, prefix = "_") {
return new Proxy(target, {
has(target, prop) {
// Hide properties starting with prefix from `in` checks
if (String(prop).startsWith(prefix)) return false;
return Reflect.has(target, prop);
},
ownKeys(target) {
// Hide from Object.keys(), for...in, JSON.stringify
return Reflect.ownKeys(target).filter(
key => !String(key).startsWith(prefix)
);
},
get(target, prop, receiver) {
if (String(prop).startsWith(prefix)) {
throw new Error(`Access denied: ${prop} is private`);
}
return Reflect.get(target, prop, receiver);
}
});
}
const api = withPrivateProps({
_secretKey: "abc123xyz",
_internalCache: {},
publicEndpoint: "/api/data",
version: "2.0"
});
console.log(api.publicEndpoint); // "/api/data"
console.log("_secretKey" in api); // false (hidden!)
console.log(Object.keys(api)); // ["publicEndpoint", "version"]
console.log(api._secretKey); // Error: Access denied: _secretKey is private
Performance Considerations
Proxy is powerful but not free. Every intercepted operation adds overhead compared to direct property access. Here's what you need to know before reaching for Proxy in performance-sensitive paths:
Measure First
Proxy overhead is typically 5-10x slower than direct property access in microbenchmarks. In most real applications this is irrelevant — profile before optimizing.
Avoid Deep Reactive Nesting Carelessly
If you proxy an object that contains nested objects, those nested objects are NOT automatically proxied. Vue 3 wraps them lazily on access. Deep nesting with eager wrapping can be costly.
Proxies Are Not Transparent to Identity Checks
proxy === target is always false. proxy instanceof SomeClass may behave unexpectedly. Use WeakMap to track the relationship between proxies and their targets.
No Revocable Proxies in Production APIs
Proxy.revocable() creates a proxy that can be invalidated. Once revoked, any access throws a TypeError. Use this for temporary capability grants, not long-lived objects.
const { proxy, revoke } = Proxy.revocable(
{ secret: "sensitive-data" },
{} // no-op handler
);
console.log(proxy.secret); // "sensitive-data"
// Revoke access after use
revoke();
try {
console.log(proxy.secret); // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch (e) {
console.error("Access denied:", e.message);
}
// Practical use: grant temporary access to a resource
function grantTemporaryAccess(resource, durationMs) {
const { proxy, revoke } = Proxy.revocable(resource, {});
setTimeout(revoke, durationMs);
return proxy;
}
const tempAccess = grantTemporaryAccess({ data: "private" }, 5000);
// tempAccess works for 5 seconds, then all access throws
Proxy vs Object.defineProperty
Before Proxy existed, developers used Object.defineProperty() for similar interception. Vue 2 was built on it. Here's why Proxy is strictly superior:
| Feature | Object.defineProperty | Proxy |
|---|---|---|
| New property detection | No (must pre-define) | Yes |
| Array index mutations | No (index = 0, 1... not detectable) | Yes |
| Array.length changes | No | Yes |
| delete operator | No | Yes (deleteProperty trap) |
| in operator | No | Yes (has trap) |
| Function call interception | No | Yes (apply trap) |
| Object.keys() interception | No | Yes (ownKeys trap) |
| Browser support | IE9+ | Edge 14+, no IE |
| Polyfillable | Yes | No (language-level feature) |
Vue 3 completely rewrote its reactivity system using Proxy. This eliminated the need for Vue.set(obj, 'newProp', value) workarounds, made arrays fully reactive, and reduced the framework's reactivity bundle size by 40%. If you're building any form of reactive state, start with Proxy.
Putting It All Together
Let's build a mini ORM-like data model that combines validation, change tracking, and computed properties — a real-world scenario that showcases all the APIs working together:
class Model {
constructor(data, schema) {
this._data = { ...data };
this._schema = schema;
this._dirty = new Set();
this._computed = {};
return new Proxy(this, {
get(target, prop, receiver) {
// Expose computed properties
if (prop in target._computed) {
return target._computed[prop](target._data);
}
// Expose raw data properties
if (prop in target._data) {
return target._data[prop];
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (prop.startsWith("_")) {
return Reflect.set(target, prop, value, receiver);
}
const rule = target._schema[prop];
if (rule && rule.type && typeof value !== rule.type) {
throw new TypeError(`${prop} must be ${rule.type}`);
}
target._data[prop] = value;
target._dirty.add(prop);
return true;
}
});
}
addComputed(name, fn) {
this._computed[name] = fn;
return this; // Chainable
}
isDirty(prop) {
return prop ? this._dirty.has(prop) : this._dirty.size > 0;
}
save() {
console.log("Saving changed fields:", [...this._dirty]);
this._dirty.clear();
}
}
const userModel = new Model(
{ firstName: "John", lastName: "Doe", age: 28 },
{ firstName: { type: "string" }, lastName: { type: "string" }, age: { type: "number" } }
);
userModel.addComputed("fullName", d => `${d.firstName} ${d.lastName}`);
userModel.addComputed("isAdult", d => d.age >= 18);
console.log(userModel.fullName); // "John Doe"
console.log(userModel.isAdult); // true
userModel.firstName = "Jane";
console.log(userModel.fullName); // "Jane Doe"
console.log(userModel.isDirty()); // true
userModel.save(); // "Saving changed fields: ['firstName']"
console.log(userModel.isDirty()); // false
All 13 Proxy Traps Quick Reference
| Trap | Triggers On |
|---|---|
get | Property reads: proxy.x, proxy['x'] |
set | Property writes: proxy.x = v |
has | in operator: 'x' in proxy |
deleteProperty | delete: delete proxy.x |
apply | Function calls: proxy(), proxy.call() |
construct | new: new proxy() |
getPrototypeOf | Object.getPrototypeOf(proxy), instanceof |
setPrototypeOf | Object.setPrototypeOf(proxy, proto) |
isExtensible | Object.isExtensible(proxy) |
preventExtensions | Object.preventExtensions(proxy) |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor(proxy, prop) |
defineProperty | Object.defineProperty(proxy, prop, desc) |
ownKeys | Object.keys(), Object.getOwnPropertyNames(), for...in |
Key Takeaways
What You've Learned
- Proxy wraps objects and intercepts operations via 13 named trap functions in a handler object
- Reflect provides the default implementations — always forward to Reflect inside your traps to avoid breaking the invariants JavaScript engines expect
- The receiver parameter in get/set traps is critical for correct prototype chain behavior with getters/setters
- set traps must return true on success or false on failure — never return nothing
- Real use cases include: reactivity systems (Vue 3), input validation, audit logging, access control, memoization, and virtual properties
- Proxy cannot be polyfilled — it's a language-level feature. Check your browser support requirements before using it
- Proxy.revocable() creates invalidatable proxies — useful for temporary capability grants
"Metaprogramming is programming about programming — and Proxy gives JavaScript developers first-class tools to reshape how their objects behave at the language level. Use this power thoughtfully."
JavaScript Proxy and Reflect unlock a new level of expressiveness in your code. Whether you're building a reactive state system, enforcing domain invariants, or creating developer-friendly APIs, these tools let you intercept reality itself — at least, the JavaScript object reality. Start small with a logging proxy on your next debugging session and work up to the full reactive patterns as your understanding grows.