Proxy and Reflect
Intercepting the Unseeable
What if you could intercept every property read, every write, every delete, every in check on an object — and run your own code instead? That's exactly what Proxy gives you. And Reflect gives you a clean way to do the "normal" thing after your interception. Together, they unlock metaprogramming — the ability to redefine how objects fundamentally behave.
And this isn't theoretical. Vue 3's entire reactivity system is built on Proxy. Libraries use it for validation, logging, memoization, and access control. Understanding Proxy is understanding how modern frameworks work under the hood.
Think of a Proxy as a security guard standing in front of an object (the "target"). Every time someone tries to access, modify, or interact with the target, the guard intercepts the attempt. The guard can allow it, deny it, modify it, or do something completely different. The Reflect API is the guard's manual — it describes the standard way to perform each operation, so the guard knows what "normal behavior" looks like.
The Basics
Let's start simple and build up.
const target = { name: "Alice", age: 30 };
const handler = {
get(target, property, receiver) {
console.log(`Reading ${property}`);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log(`Writing ${property} = ${value}`);
return Reflect.set(target, property, value, receiver);
}
};
const proxy = new Proxy(target, handler);
proxy.name; // Logs: "Reading name", returns "Alice"
proxy.age = 31; // Logs: "Writing age = 31"
The handler object defines traps — functions that intercept specific operations. There are 13 traps, one for each fundamental operation in the spec.
The 13 Traps
| Trap | Intercepts | Example |
|---|---|---|
get | Property read | obj.prop, obj[key] |
set | Property write | obj.prop = val |
has | in operator | "prop" in obj |
deleteProperty | delete operator | delete obj.prop |
apply | Function call | fn(), fn.call() |
construct | new operator | new Fn() |
getPrototypeOf | Object.getPrototypeOf | |
setPrototypeOf | Object.setPrototypeOf | |
isExtensible | Object.isExtensible | |
preventExtensions | Object.preventExtensions | |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor | |
defineProperty | Object.defineProperty | |
ownKeys | Object.keys, for...in |
The Reflect API — The Default Behavior Manual
This is the part most tutorials rush through, but it's crucial. Every Proxy trap has a corresponding Reflect method that performs the default behavior. Always use Reflect to forward operations in traps:
// BAD — doesn't handle edge cases
get(target, prop) {
return target[prop]; // Misses: receiver, prototype chain, getters
}
// GOOD — correct forwarding
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
Using target[prop] instead of Reflect.get(target, prop, receiver) inside a get trap breaks getter behavior. If the property is a getter, target[prop] sets this to target (the raw object), but Reflect.get with receiver sets this to the proxy. This matters when the getter accesses other properties — they should also go through the proxy, not bypass it:
const target = {
_name: "Alice",
get name() { return this._name; }
};
const proxy = new Proxy(target, {
get(target, prop, receiver) {
console.log(`trap: ${prop}`);
return target[prop]; // BUG: this._name bypasses the proxy
// return Reflect.get(target, prop, receiver); // CORRECT
}
});
proxy.name;
// With target[prop]: only logs "trap: name" (._name access is not trapped)
// With Reflect.get: logs "trap: name" AND "trap: _name"Real Use Case 1: Validation
function createValidated(schema) {
return new Proxy({}, {
set(target, property, value) {
const validator = schema[property];
if (validator && !validator(value)) {
throw new TypeError(
`Invalid value for ${property}: ${JSON.stringify(value)}`
);
}
return Reflect.set(target, property, value);
}
});
}
const user = createValidated({
name: v => typeof v === "string" && v.length > 0,
age: v => Number.isInteger(v) && v >= 0 && v <= 150,
email: v => typeof v === "string" && v.includes("@"),
});
user.name = "Alice"; // OK
user.age = 30; // OK
user.age = -5; // TypeError: Invalid value for age: -5
user.email = "bad"; // TypeError: Invalid value for email: "bad"
Real Use Case 2: Observable / Reactive (Vue 3 Pattern)
Now we're getting to the good stuff. This is basically the core of Vue 3's reactivity:
function reactive(target) {
const subscribers = new Map();
return new Proxy(target, {
get(target, property, receiver) {
// Track which properties are accessed during render
trackDependency(subscribers, property);
const value = Reflect.get(target, property, receiver);
// If the value is an object, make it reactive too (deep reactivity)
return typeof value === "object" && value !== null
? reactive(value)
: value;
},
set(target, property, value, receiver) {
const oldValue = target[property];
const result = Reflect.set(target, property, value, receiver);
if (oldValue !== value) {
// Notify all watchers of this property
triggerSubscribers(subscribers, property);
}
return result;
}
});
}
Why Vue 3 switched from Object.defineProperty to Proxy
Vue 2 used Object.defineProperty to make objects reactive. This had limitations:
- New properties: You couldn't detect when a new property was added (hence
Vue.set()) - Array indices: Setting
arr[index] = valuecouldn't be intercepted - Array length: Changing
arr.lengthwasn't reactive - Performance: Every property needed its own getter/setter at initialization
Proxy solves all of these. It intercepts any operation on the object, including property addition, deletion, and in checks. Vue 3's reactivity is more complete and performs better because of this switch.
Real Use Case 3: Negative Array Indices
function createArray(...items) {
return new Proxy(items, {
get(target, property, receiver) {
const index = Number(property);
if (Number.isInteger(index) && index < 0) {
return Reflect.get(target, String(target.length + index), receiver);
}
return Reflect.get(target, property, receiver);
}
});
}
const arr = createArray("a", "b", "c", "d");
arr[-1]; // "d"
arr[-2]; // "c"
arr[0]; // "a"
Real Use Case 4: Access Logging / Debugging
function withLogging(target, label = "Object") {
return new Proxy(target, {
get(target, prop, receiver) {
if (typeof prop === "string") {
console.log(`[${label}] GET .${prop}`);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`[${label}] SET .${prop} = ${JSON.stringify(value)}`);
return Reflect.set(target, prop, value, receiver);
},
deleteProperty(target, prop) {
console.log(`[${label}] DELETE .${prop}`);
return Reflect.deleteProperty(target, prop);
}
});
}
The Performance Cost
Before you go wrapping everything in Proxies, you need to know this: proxies are not free. Every trapped operation has overhead:
// Benchmark comparison (approximate, V8)
const obj = { x: 1 };
const proxy = new Proxy(obj, { get: Reflect.get });
// Direct access: ~0.5ns per operation
// Proxy access: ~50ns per operation (100x slower)
Don't use Proxy on hot paths — tight loops, animation frames, or frequently accessed data in render functions. The 100x overhead is negligible for occasional access (UI events, configuration) but devastating in hot paths. Vue 3 mitigates this with careful caching and avoiding unnecessary reactive wrapping of primitives.
Proxy Invariants — The Rules You Can't Break
You might think a Proxy can do anything. It can't. The spec enforces invariants — rules that prevent proxies from lying about the target:
const target = {};
Object.defineProperty(target, "locked", {
value: 42,
writable: false,
configurable: false
});
const proxy = new Proxy(target, {
get() { return 99; } // Try to lie about the value
});
proxy.locked; // TypeError: proxy get handler returned 99 for
// non-writable, non-configurable property "locked"
If the target has a non-writable, non-configurable property, the proxy's get trap must return the same value. This prevents proxies from making frozen objects appear mutable or hiding sealed properties.
Production Scenario: Type-Safe API Client
function createApiClient(baseUrl) {
return new Proxy({}, {
get(_, resource) {
// api.users → fetch /users
// api.posts → fetch /posts
return {
async list(params) {
const url = new URL(`${baseUrl}/${resource}`);
Object.entries(params || {}).forEach(([k, v]) =>
url.searchParams.set(k, v)
);
const res = await fetch(url);
return res.json();
},
async get(id) {
const res = await fetch(`${baseUrl}/${resource}/${id}`);
return res.json();
},
async create(data) {
const res = await fetch(`${baseUrl}/${resource}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
return res.json();
}
};
}
});
}
const api = createApiClient("https://api.example.com");
await api.users.list({ page: 1 }); // GET /users?page=1
await api.posts.get(42); // GET /posts/42
await api.comments.create({ text: "hi" }); // POST /comments
| What developers do | What they should do |
|---|---|
| Using target[prop] instead of Reflect.get in traps target[prop] bypasses the proxy for nested property access and breaks getter 'this' binding | Always use Reflect methods to forward operations — they handle receiver, getters, and prototype chains correctly |
| Using Proxy on hot paths (tight loops, animation frames) Proxy trap invocation adds ~100x overhead compared to direct property access | Use Proxy for infrequent operations (config, validation, API clients). Use plain objects for hot paths. |
| Trying to make a proxy lie about frozen properties The spec enforces invariants. Violating them throws TypeError to prevent a proxy from breaking object guarantees. | Respect invariants — proxy traps must be consistent with target's property descriptors |
| Forgetting to return true from set traps Returning false or undefined from set causes TypeError in strict mode | The set trap must return true to indicate success (or the assignment throws TypeError in strict mode) |
- 1Proxy intercepts 13 fundamental operations (get, set, has, delete, apply, construct, etc.) through handler traps
- 2Always use Reflect methods inside traps to perform the default operation — they handle receiver, getters, and prototype chains correctly
- 3Proxy has ~100x overhead per trapped operation — avoid on hot paths, use for config/validation/API patterns
- 4Proxy invariants prevent traps from lying about frozen or non-configurable properties — violations throw TypeError
- 5Vue 3 uses Proxy for reactivity — this is the mechanism that tracks dependencies and triggers re-renders