← blog
JavaScriptAugust 24, 2026 · 15 min read

JavaScript Proxy and Reflect: The Complete Guide

Learn JavaScript Proxy and Reflect from the ground up — traps, invariants, and reactive state — with worked examples and a copy-paste cheat sheet.

Parsa Jiravand · Frontend engineer · building bestpractic
JavaScript Proxy and Reflect: The Complete Guide

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.

By the end of this guide you'll be able to:

  • Explain what a Proxy actually is — a stand-in that intercepts operations on an object, not a copy or a wrapper class
  • Write get, set, has, deleteProperty, and ownKeys traps to validate, hide, and log property access
  • Use Reflect correctly, 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].

Here's the naive fix for "validate this field whenever it's set" — a hand-written getter/setter pair:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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 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.

JavaScript
1
2
3
4
5
6
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.

The two most common traps intercept reading and writing a property:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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"

Now replace the User class's boilerplate with one reusable set trap and a table of rules:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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.

Runs right in your browser — poke at it and watch the concept react live.

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.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

JavaScript
1
2
3
4
5
6
7
8
9
10
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

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.

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:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.

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:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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.

  • Identity is not preserved. proxy !== target. If other code holds a reference to the raw target and compares it with === against the proxy, or stores one in a Set/Map and 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.
  • Map and Set can't be proxied directly. Wrapping a real Map or Set in a Proxy and calling .get()/.set()/.add() on the proxy throws a TypeError ("Method Map.prototype.get called on incompatible receiver"), because those methods depend on an internal slot that only exists on genuine Map/Set instances — a Proxy is a different kind of exotic object and doesn't have it. If you need to intercept a Map, wrap the methods explicitly rather than proxying the instance.
  • Invariants are enforced by the engine, not by you. If target has a non-configurable, non-writable own property, a get trap that returns anything other than the real value throws a TypeError — you cannot lie about a property the engine considers frozen. Similarly, ownKeys must include every non-configurable own key of target or the call throws.
  • Destructuring a method loses the receiver, same as any object. const { subscribe } = state; then calling subscribe() alone runs with this as undefined in 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 — after revoke(), every operation on proxy throws. 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/set trap 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.

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.

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.

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.

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.

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.

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.

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.

TrapInterceptsMatching Reflect callNotes
getobj.prop, obj[prop]Reflect.get(t, p, r)Must return target's real value for non-configurable, non-writable props
setobj.prop = vReflect.set(t, p, v, r)Must return true/truthy or a TypeError is thrown
has"prop" in objReflect.has(t, p)Doesn't block reads — combine with get to fully hide a key
deletePropertydelete obj.propReflect.deleteProperty(t, p)Return false to reject the delete
ownKeysObject.keys, for...in, JSON.stringifyReflect.ownKeys(t)Must include every non-configurable own key
getOwnPropertyDescriptorObject.getOwnPropertyDescriptorReflect.getOwnPropertyDescriptor(t, p)Pair with ownKeys when hiding a key
applycalling the proxy as a functionReflect.apply(fn, this, args)Only relevant if target is callable
constructnew proxy(...)Reflect.construct(Ctor, args)Only relevant if target is a constructor
Proxy.revocable(t, h)Returns { proxy, revoke }; revoke() disables the proxy permanently
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

  • A Proxy intercepts fundamental operations on an object — it is a stand-in in front of the target, not a copy of it, and proxy !== target.
  • Every trap has a matching Reflect method that performs that operation's true default behavior, including forwarding the receiver — use Reflect.get(target, prop, receiver), not target[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 a Proxy doesn't carry.
  • Reach for Proxy when behavior is cross-cutting across every property (validation, reactivity, logging); reach for a plain getter/setter, or structuredClone for 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:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.