You set user.age = -5 on a plain object and nothing stops you. No error, no warning — the object silently accepts a value that makes no sense, and the bug surfaces three files away, in whatever code trusted age to be a real number. Every framework that seems to "just know" when your state changed — Vue's reactivity, a validation library that rejects bad input at the boundary, an ORM that lazy-loads a relation the moment you touch it — is solving this exact problem with one native JavaScript feature that most tutorials skip past in a paragraph: Proxy.
What you'll learn
By the end of this guide you'll be able to:
- Explain what a
Proxyactually is — a stand-in that intercepts operations on an object, not a copy or a wrapper class - Write
get,set,has,deleteProperty, andownKeystraps to validate, hide, and log property access - Use
Reflectcorrectly, and explain the one bug it exists to prevent - Build a small reactive-state system — the same mechanism Vue 3 uses under the hood
- Recognize the invariants, gotchas, and performance tradeoffs that catch people in production
Who this is for: you write JavaScript or TypeScript day to day, you've used objects and classes comfortably, and you've heard of Proxy but never reached for it — or you've seen Reflect.get(target, prop, receiver) in someone else's code and wondered why they didn't just write target[prop].
Contents
- Why JavaScript Proxy exists
- The mental model: a checkpoint in front of every operation
- Stage 1: your first proxy — a get and set trap
- Stage 2: validation without a setter for every field
- Stage 3: Reflect, and why the receiver matters
- Stage 4: hiding and protecting properties
- Stage 5: building a tiny reactive system
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Why JavaScript Proxy exists
Here's the naive fix for "validate this field whenever it's set" — a hand-written getter/setter pair:
// the wrong way — one getter/setter pair per field, and it doesn't scale
class User {
#age;
constructor(age) {
this.age = age;
}
get age() {
return this.#age;
}
set age(value) {
if (typeof value !== "number" || value < 0) {
throw new TypeError("age must be a non-negative number");
}
this.#age = value;
}
}
const user = new User(30);
user.age = -5; // ✅ correctly throws
This works — for age. Add email, score, and role, and you're maintaining four nearly identical getter/setter pairs, each one a place to forget the check. Miss one, and that field silently accepts garbage, exactly like the plain object at the top of this article. The validation logic is also scattered per-field instead of living in one place you can audit.
What you actually want is a way to say "run this code whenever any property is read or written on this object" — one interception point, not N hand-written pairs. That's precisely what Proxy gives you, and Reflect is the toolkit that makes writing traps correctly possible.
The mental model: a checkpoint in front of every operation
The mental model: a Proxy is not the object — it's a stand-in that sits in front of the real object (the target) and intercepts a fixed set of fundamental operations: reading a property, writing one, checking in, deleting, listing keys, and a few others. Each operation you intercept is called a trap. If you don't define a trap for an operation, it passes straight through to the target, unchanged — and Reflect is how you perform that same "pass it through" behavior explicitly, from inside a trap you did define.
Think of it like a customs checkpoint at a border. Most traffic (an operation with no trap) just walks through untouched. But for the operations you care about, you install an inspector (the trap function) who can log the traffic, reject it, alter it, or wave it through — and when they wave it through, they're not improvising; they're calling the same official procedure (Reflect) that would have run automatically if no inspector were there at all.
const target = { name: "Ada", age: 36 };
const proxy = new Proxy(target, {
/* traps go here — every operation without one passes straight through to target */
});
proxy.name; // "Ada" — no `get` trap defined, so this passes straight through
Every stage below is this one idea, applied to a different operation.
Stage 1: your first proxy — a get and set trap
The two most common traps intercept reading and writing a property:
const target = { name: "Ada", age: 36 };
const logged = new Proxy(target, {
get(obj, prop) {
console.log(`read ${String(prop)}`);
return obj[prop];
},
set(obj, prop, value) {
console.log(`write ${String(prop)} = ${value}`);
obj[prop] = value;
return true; // required: signals the write succeeded
},
});
logged.name; // logs "read name", returns "Ada"
logged.age = 37; // logs "write age = 37"
Key concept: one
get/setpair intercepts every property on the object, in one place — not one pair per field. Thesettrap must returntrue(or any truthy value); return a falsy value and JavaScript throws aTypeError, because the engine treats a falsy return as "this write failed."
Stage 2: validation without a setter for every field
Now replace the User class's boilerplate with one reusable set trap and a table of rules:
function validated(target, rules) {
return new Proxy(target, {
set(obj, prop, value) {
const rule = rules[prop];
if (rule && !rule(value)) {
throw new TypeError(`invalid value for ${String(prop)}: ${value}`);
}
obj[prop] = value;
return true;
},
});
}
const user = validated(
{ name: "Ada", age: 36 },
{ age: (v) => typeof v === "number" && v >= 0 }
);
user.age = 37; // ✅ passes the rule, write proceeds
user.age = -5; // ❌ TypeError: invalid value for age: -5
Adding a validated field for email or score is now a one-line rule in the rules object, not a new getter/setter pair. The check lives in exactly one place — the set trap — no matter how many fields you validate. In TypeScript, validated is worth making generic in its own right, so the object you get back keeps the exact shape of the object you passed in — the same type-parameter-as-argument idea covered in the guide to TypeScript generics.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Stage 3: Reflect, and why the receiver matters
Stage 1's traps forwarded reads and writes with obj[prop] directly. That works for plain data, but it quietly breaks once a getter and a prototype chain are involved — and this is the exact bug Reflect exists to prevent.
const target = {
get self() {
return this;
},
};
const handler = {
get(target, prop) {
return target[prop]; // ❌ forwards using `target` as `this`, not the actual receiver
},
};
const proxy = new Proxy(target, handler);
const obj = Object.create(proxy);
console.log(obj.self === obj); // false — `this` inside the getter was bound to `target`
obj.self should return obj — that's what a getter returning this means when you access it through obj. But the trap wrote target[prop], so the getter ran with this bound to target, not obj. The fix is to forward the operation with Reflect.get, which takes a third argument — the receiver — and passes it through as this:
const handler2 = {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver); // forwards the real receiver as `this`
},
};
const proxy2 = new Proxy(target, handler2);
const obj2 = Object.create(proxy2);
console.log(obj2.self === obj2); // true — Reflect.get passed `obj2` through as the receiver
Key concept: every trap's default behavior — what would happen with no trap at all — is exactly what its matching
Reflectmethod does.target[prop]looks equivalent, but it silently drops the receiver;Reflect.get(target, prop, receiver)is the one that actually replicates the engine's own default.
Reflect isn't a Proxy-only feature — it mirrors all 13 of the fundamental object operations (get, set, has, deleteProperty, ownKeys, getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, defineProperty, getOwnPropertyDescriptor, apply, construct) as plain functions instead of operators or statements. Outside a Proxy trap, that mostly matters for two things: Reflect.ownKeys(obj) gets you every own key (strings and symbols) in one call, and Reflect.construct(Ctor, args) calls a constructor with a dynamic argument list without new Ctor(...args)'s syntax constraints.
Stage 4: hiding and protecting properties
Traps aren't limited to get/set. has intercepts the in operator, deleteProperty intercepts delete, and ownKeys intercepts Object.keys, for...in, and JSON.stringify:
const secretHandler = {
ownKeys(target) {
return Reflect.ownKeys(target).filter((k) => k !== "password");
},
getOwnPropertyDescriptor(target, prop) {
if (prop === "password") return undefined;
return Reflect.getOwnPropertyDescriptor(target, prop);
},
has(target, prop) {
return prop === "password" ? false : Reflect.has(target, prop);
},
};
const account = new Proxy({ user: "ada", password: "hunter2" }, secretHandler);
Object.keys(account); // ["user"]
JSON.stringify(account); // '{"user":"ada"}'
"password" in account; // false
account.password; // still "hunter2" — no `get` trap was defined here
That last line matters: hiding a key from enumeration (ownKeys/has) is a different guarantee from blocking direct access (get). This example only hides password from listing and serialization — anyone who already knows the key name can still read it. If you want both, add a get trap that throws or returns undefined for that key.
Stage 5: building a tiny reactive system
This is the payoff: the same mechanism that powers Vue 3's reactivity system (Vue 2 used Object.defineProperty; Vue 3's official migration guide documents the switch to Proxy), stripped to its essence — a set trap that notifies subscribers whenever a value actually changes:
function reactive(obj) {
const subscribers = new Set();
const proxy = new Proxy(obj, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
const changed = target[prop] !== value;
const result = Reflect.set(target, prop, value, receiver);
if (changed) subscribers.forEach((fn) => fn(prop, value));
return result;
},
});
return { proxy, subscribe: (fn) => subscribers.add(fn) };
}
const { proxy: state, subscribe } = reactive({ count: 0 });
subscribe((prop, value) => console.log(`${prop} changed to ${value}`));
state.count++; // logs "count changed to 1" — a plain increment triggered the subscriber
No state.setCount(...) call, no manual "mark dirty" step — state.count++ is ordinary JavaScript, and the set trap is where the framework hooks in to schedule a re-render. This is also why reactive frameworks generally avoid diffing entire objects on every render: the proxy already knows exactly which property changed, the moment it changes.
Edge cases and gotchas
-
Identity is not preserved.
proxy !== target. If other code holds a reference to the rawtargetand compares it with===against theproxy, or stores one in aSet/Mapand looks up the other, the comparison fails. Always thread the proxy through consistently — don't mix references to the target and the proxy for the same logical object. -
MapandSetcan't be proxied directly. Wrapping a realMaporSetin aProxyand calling.get()/.set()/.add()on the proxy throws aTypeError("Method Map.prototype.get called on incompatible receiver"), because those methods depend on an internal slot that only exists on genuineMap/Setinstances — aProxyis a different kind of exotic object and doesn't have it. If you need to intercept aMap, wrap the methods explicitly rather than proxying the instance. -
Invariants are enforced by the engine, not by you. If
targethas a non-configurable, non-writable own property, agettrap that returns anything other than the real value throws aTypeError— you cannot lie about a property the engine considers frozen. Similarly,ownKeysmust include every non-configurable own key oftargetor the call throws. -
Destructuring a method loses the receiver, same as any object.
const { subscribe } = state;then callingsubscribe()alone runs withthisasundefinedin strict mode — this isn't Proxy-specific, but it's easy to trip over once you've wrapped an object in traps and assume the wrapping changes calling conventions. It doesn't. -
Revocable proxies exist for exactly one purpose: capability revocation.
const { proxy, revoke } = Proxy.revocable(target, handler);gives you a proxy you can permanently disable later — afterrevoke(), every operation onproxythrows. Useful for handing out a reference that must stop working once a session ends or a component unmounts, without tracking down every place that reference was passed. -
Every fundamental operation becomes a function call. A
get/settrap runs real JavaScript on every property access, which is measurably slower than a plain object for extremely hot loops touching millions of properties. This rarely matters for UI state or validation layers; it does matter if you're tempted to proxy a tight numerical loop.
Best practices: when (not) to reach for Proxy
Reach for a Proxy when the behavior is cross-cutting — it applies to every property, not one: validation layers, reactive state, logging/instrumentation, lazy-loading a relation the first time it's touched, or sandboxing a reference you may need to revoke later.
Don't reach for a Proxy when a single field needs a single check — a plain getter/setter pair on a class is clearer and faster for that one case. Reserve Proxy for when you'd otherwise be copy-pasting the same trap logic across several fields.
Don't reach for a Proxy to copy or clone data. A Proxy intercepts operations on the original object — it is not a copy. If what you actually need is an independent snapshot of an object's current data, that's structuredClone, not a Proxy — the two solve opposite problems and are easy to reach for interchangeably by mistake.
Don't proxy built-ins directly. As the gotchas above show, Map, Set, Date, and similar built-ins carry internal slots a Proxy can't forward. Wrap the specific methods you need instead of proxying the instance.
FAQ
What is the difference between Object.defineProperty and Proxy?
Object.defineProperty configures one property on one object at a time — you call it once per field you want to intercept. A Proxy wraps the entire object with a single set of traps that apply to every property, including ones added later, which is why Vue 3 moved from the former to the latter.
Does Reflect replace Proxy?
No — they're complementary, not alternatives. Proxy is how you intercept an operation; Reflect is how you correctly perform that operation's default behavior (including forwarding the receiver) from inside the trap you wrote.
Can I use Proxy on an array?
Yes. Array index access, length, and methods like push all go through the same get/set traps (array indices are just string-keyed properties under the hood). A set trap on an array proxy fires once per element write, including the implicit length update that array mutation methods perform.
Can I proxy a Map or a Set?
Not directly — see the gotchas section above. Calling a Map/Set method on a Proxy wrapping one throws a TypeError, because those methods require an internal slot only real Map/Set instances have.
Is a Proxy the same type as its target?
typeof proxy matches typeof target (both "object", or "function" if the target is callable and you defined apply/construct traps), and instanceof checks pass through correctly. But proxy !== target — they are not the same reference, which matters for equality checks and collection membership.
Does JSON.stringify work on a Proxy?
Yes, and it respects your traps: JSON.stringify reads properties through ownKeys, getOwnPropertyDescriptor, and get, in that order, so a proxy that hides or transforms properties in those traps produces correspondingly different JSON — exactly as shown in Stage 4.
Cheat sheet
| Trap | Intercepts | Matching Reflect call | Notes |
|---|---|---|---|
get |
obj.prop, obj[prop]
|
Reflect.get(t, p, r) |
Must return target's real value for non-configurable, non-writable props |
set |
obj.prop = v |
Reflect.set(t, p, v, r) |
Must return true/truthy or a TypeError is thrown |
has |
"prop" in obj |
Reflect.has(t, p) |
Doesn't block reads — combine with get to fully hide a key |
deleteProperty |
delete obj.prop |
Reflect.deleteProperty(t, p) |
Return false to reject the delete |
ownKeys |
Object.keys, for...in, JSON.stringify
|
Reflect.ownKeys(t) |
Must include every non-configurable own key |
getOwnPropertyDescriptor |
Object.getOwnPropertyDescriptor |
Reflect.getOwnPropertyDescriptor(t, p) |
Pair with ownKeys when hiding a key |
apply |
calling the proxy as a function | Reflect.apply(fn, this, args) |
Only relevant if target is callable |
construct |
new proxy(...) |
Reflect.construct(Ctor, args) |
Only relevant if target is a constructor |
Proxy.revocable(t, h) |
— | — | Returns { proxy, revoke }; revoke() disables the proxy permanently |
// The whole pattern, copy-paste ready: validated + reactive state, correctly forwarded
function reactiveValidated(obj, rules = {}) {
const subscribers = new Set();
const proxy = new Proxy(obj, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver); // always forward the receiver
},
set(target, prop, value, receiver) {
const rule = rules[prop];
if (rule && !rule(value)) {
throw new TypeError(`invalid value for ${String(prop)}: ${value}`);
}
const changed = target[prop] !== value;
const result = Reflect.set(target, prop, value, receiver);
if (changed) subscribers.forEach((fn) => fn(prop, value));
return result; // must be truthy, or JS throws for you
},
});
return { proxy, subscribe: (fn) => subscribers.add(fn) };
}
const { proxy: state, subscribe } = reactiveValidated(
{ age: 30 },
{ age: (v) => typeof v === "number" && v >= 0 }
);
subscribe((prop, value) => console.log(`${prop} -> ${value}`));
state.age = 31; // ✅ logs "age -> 31"
state.age = -1; // ❌ throws before the subscriber ever runs
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Key takeaways
- A
Proxyintercepts fundamental operations on an object — it is a stand-in in front of the target, not a copy of it, andproxy !== target. - Every trap has a matching
Reflectmethod that performs that operation's true default behavior, including forwarding the receiver — useReflect.get(target, prop, receiver), nottarget[prop], inside a trap. - Hiding a property from enumeration (
ownKeys/has) and blocking direct access (get) are separate guarantees — combine the traps you actually need. -
Map,Set, and similar built-ins can't be proxied directly because their methods depend on internal slots aProxydoesn't carry. - Reach for
Proxywhen behavior is cross-cutting across every property (validation, reactivity, logging); reach for a plain getter/setter, orstructuredClonefor copies, when it isn't.
That silent -5 from the top of this article never had a chance to happen in Stage 2 — one set trap rejected it before it ever reached the object. You now have the mechanism behind it: a checkpoint in front of the object, Reflect to forward what you don't intercept, and enough of the gotchas to avoid the ones that catch people in production. Where's the first validation class or manual "notify on change" pattern in your own code that a five-line Proxy could replace? Tell me in the comments.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (2)
The receiver/getter bug in Stage 3 is the one that actually bites people, I've seen target[prop] forwarding pass code review plenty of times since it works fine until someone adds a getter or subclasses the target later. On the array proxy note in the gotchas, do you know if Vue 3 does anything special to avoid the per-element set trap cost on large array mutations, or does it just eat that cost since arrays are rarely the hot path in typical UI state?
Absolutely. The receiver bug is a nasty one because the naive target[prop] forwarding looks completely correct until the target starts using accessors or inheritance. Reflect.get(target, prop, receiver) is one of those cases where the extra argument really matters.
On Vue 3, I wouldn't say it simply eliminates the per-element cost. Vue's reactivity system does use proxies for reactive arrays, so operations that trigger multiple property mutations can involve multiple proxy traps and dependency checks. It does, however, have specialized array instrumentation for common methods and tracks/invalidates dependencies carefully rather than treating every operation as a completely generic property access.
In practice, I think the bigger takeaway is that Vue's proxy overhead is usually acceptable for normal UI state, but repeatedly mutating very large reactive arrays can still become a hot path. That's where batching, shallow/non-reactive data, or changing the data structure can matter more than trying to optimize the proxy itself.
Really good point about the getter/subclass case, though—that's exactly the kind of proxy bug that can survive code review because the simple cases all pass.