Mert Özen Further With Every Line
Frontend JavaScript Advanced — #08

JavaScript Proxy and Reflect: Traps, Validation and Reactivity

Mert Özen Nov 3, 2026 9 min 6 views
JavaScript Proxy and Reflect: Traps, Validation and Reactivity

We intercept reads, writes and deletes on an object with Proxy and keep default behavior with Reflect, then build validation, default values and a reactive UI while covering the pitfalls and the cost.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Let's build a small wrapper around an object that returns "none" for properties that don't exist. What does this code print, line by line?

const target = { name: "Ada" };

const p = new Proxy(target, {
  get(t, key) {
    return key in t ? t[key] : "none";
  }
});

p.age = 36;
console.log(p.surname);
console.log(target.age);
console.log(p === target);

Make a guess for all three lines before you keep reading. Pay special attention to the middle one: we only intercepted reads and never touched writes.

What Is a Proxy? A Gatekeeper in the Middle

Imagine you want to reach a company's CEO. You can't just walk into their office; you talk to their assistant first. The assistant passes some questions straight through, answers some personally, and turns others away at the door. The CEO doesn't change, but every contact you have with them now goes across the assistant's desk.

Proxy, which arrived in ES2015, is exactly that assistant. With new Proxy(target, handler) you create a new object that wraps the target. The handler is an ordinary object holding functions called traps: get intercepts reads, set intercepts writes, has intercepts the in operator, deleteProperty intercepts delete, and there are a few more.

Here's what happens on the engine's side: every operation you perform on an object in JavaScript actually turns into an internal method. When you write obj.x, the engine runs [[Get]]; when you write obj.x = 1, it runs [[Set]]. A proxy lets you hook into each of these internal methods. If the handler has no matching trap, the operation is passed on to the target as if nothing happened.

The First Traps: get, set, has, deleteProperty

Let's look at the four basic traps with a proxy that reports every operation to the console. Paste this into your browser's console and try it:

const user = { name: "Ada", role: "admin" };

const watched = new Proxy(user, {
  get(target, key) {
    console.log(`read: ${String(key)}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`written: ${String(key)} = ${value}`);
    target[key] = value;
    return true;
  },
  has(target, key) {
    console.log(`checked: ${String(key)}`);
    return key in target;
  },
  deleteProperty(target, key) {
    console.log(`deleted: ${String(key)}`);
    return delete target[key];
  }
});

watched.name;
watched.age = 36;
"role" in watched;
delete watched.role;
console.log(user); // { name: "Ada", age: 36 }

Every trap receives the target object as its first argument and the property key as its second. The key can be a string or a symbol, which is why we wrapped it in String() before putting it into a template literal; otherwise a symbol key would make the code throw a TypeError. set and deleteProperty also return a boolean: true means "the operation succeeded".

Now back to the opening guess. p.surname isn't on the target, so it prints "none". On the p.age = 36 line, the handler has no set trap, so the write goes straight through to the target and target.age becomes 36. The last line is false: a proxy isn't the target itself but a separate object wrapped around it.

Reflect: The Default Behavior Itself

To say "do whatever would normally happen" inside a trap, we wrote target[key]. That works most of the time, but it has a subtle gap. The Reflect object, which arrived alongside Proxy in ES2015, closes that gap. Reflect's methods have exactly the same names and parameters as the traps: Reflect.get, Reflect.set, Reflect.has, Reflect.deleteProperty, Reflect.ownKeys and the rest. Each one runs the default version of that internal operation.

The easiest way to see the difference is with a getter:

const person = {
  name: "Ada",
  surname: "Lovelace",
  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

const leaky = new Proxy(person, {
  get(target, key) {
    console.log("get:", key);
    return target[key];
  }
});

const solid = new Proxy(person, {
  get(target, key, receiver) {
    console.log("get:", key);
    return Reflect.get(target, key, receiver);
  }
});

leaky.fullName; // get: fullName
solid.fullName; // get: fullName, get: name, get: surname

The third argument of the get trap, receiver, is the object the read was actually made on; here, the proxy itself. When you write target[key], the getter sees the target as this, and the this.name read skips the proxy entirely. Reflect.get(target, key, receiver) runs the getter with the proxy as this, so the inner reads go through the trap too. For jobs that need to know about every single read, like reactivity, this difference is crucial.

There's one more upside: Reflect.set and Reflect.deleteProperty return a boolean instead of throwing. That value is the same one the trap needs to return, so you can simply write return Reflect.set(...).

Validation: Stopping Bad Data at the Door

One of the most common uses of a proxy is checking the data that goes into an object. Let's write a small helper that takes its rules from outside:

function validated(target, rules) {
  return new Proxy(target, {
    set(t, key, value, receiver) {
      const rule = rules[key];
      if (rule && !rule(value)) {
        throw new TypeError(`Invalid value: ${String(key)} = ${value}`);
      }
      return Reflect.set(t, key, value, receiver);
    }
  });
}

const product = validated({}, {
  name: (v) => typeof v === "string" && v.trim() !== "",
  price: (v) => typeof v === "number" && v >= 0
});

product.name = "Keyboard";
product.price = 750;

try {
  product.price = -5;
} catch (e) {
  console.log(e.message); // Invalid value: price = -5
}
console.log(product.name, product.price); // Keyboard 750

Why did we throw an error instead of returning false? Because the effect of returning false from a set trap depends on the code. In strict mode, meaning in modules and class bodies, the engine turns it into a TypeError. In code running in sloppy mode, like the console, the assignment is silently ignored; you get no error and the value isn't written. Throwing an error with your own message makes it clear what happened in both cases.

Default Values and Hidden Fields

The get trap also leaves it up to you what to answer for things that don't exist. Here's a settings object that only stores what the user changed and reads the rest from defaults:

const defaults = { theme: "light", lang: "en", fontSize: 16 };

function createSettings(userSettings) {
  return new Proxy(userSettings, {
    get(t, key, receiver) {
      if (Reflect.has(t, key)) return Reflect.get(t, key, receiver);
      return defaults[key];
    }
  });
}

const settings = createSettings({ theme: "dark" });
console.log(settings.theme, settings.lang, settings.fontSize); // dark en 16

Combine a few traps and things get more interesting. Let's make properties that start with an underscore invisible from the outside: they can't be read, can't be found with in, don't show up in Object.keys and can't be deleted.

function hide(target) {
  const isHidden = (k) => typeof k === "string" && k.startsWith("_");
  return new Proxy(target, {
    get: (t, k, r) => (isHidden(k) ? undefined : Reflect.get(t, k, r)),
    has: (t, k) => (isHidden(k) ? false : Reflect.has(t, k)),
    ownKeys: (t) => Reflect.ownKeys(t).filter((k) => !isHidden(k)),
    deleteProperty: (t, k) => (isHidden(k) ? false : Reflect.deleteProperty(t, k))
  });
}

const account = hide({ owner: "Ada", _internalId: 4821 });
console.log(account._internalId);      // undefined
console.log("_internalId" in account); // false
console.log(Object.keys(account));     // ["owner"]

The ownKeys trap intercepts every operation that lists keys, such as Object.keys, for...in and Reflect.ownKeys. Still, don't mistake this for a real security measure: anyone holding a reference to the target can access it directly. For real privacy, the #private fields or closures we saw earlier are the right tools.

Update the Screen When the Data Changes

Here's the idea that makes proxies so appealing: since you know the moment something is written to an object, you can update the screen on every write. The chance of forgetting to say "now refresh the screen" disappears. Save the following as an HTML file and open it in your browser:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Reactivity with Proxy</title>
</head>
<body>
  <input id="name" placeholder="Your name">
  <button id="like">Like</button>
  <p id="output"></p>

  <script>
    function reactive(initial, render) {
      const state = new Proxy(initial, {
        set(t, key, value, receiver) {
          const result = Reflect.set(t, key, value, receiver);
          render(state);
          return result;
        }
      });
      render(state);
      return state;
    }

    const output = document.querySelector("#output");

    const state = reactive({ name: "guest", likes: 0 }, (s) => {
      output.textContent = `Hello ${s.name}, ${s.likes} likes`;
    });

    document.querySelector("#name").addEventListener("input", (e) => {
      state.name = e.target.value || "guest";
    });
    document.querySelector("#like").addEventListener("click", () => {
      state.likes++;
    });
  </script>
</body>
</html>

We didn't touch the DOM even once inside the event listeners; we only changed the state object. Updating the screen is entirely the set trap's responsibility. This small example carries the core idea behind reactive interfaces. A real system adds a few more steps: batching several changes made at once into a single render, or tracking with get which field affects which part of the page and updating only that part.

Common Pitfalls

Some built-in objects don't play well with proxies. Map, Set, Date and DOM elements keep their data not in properties but in hidden internal slots. Their methods also expect this to be a real Map. Call them through a proxy and this becomes the proxy, so you get a TypeError. The fix is to bind functions to the target:

const map = new Map([["a", 1]]);

const broken = new Proxy(map, {});
try {
  broken.get("a");
} catch (e) {
  console.log(e.constructor.name); // TypeError
}

const working = new Proxy(map, {
  get(t, k) {
    const value = Reflect.get(t, k, t);
    return typeof value === "function" ? value.bind(t) : value;
  }
});
console.log(working.get("a"), working.size); // 1 1

Here we deliberately passed t instead of receiver; size is a getter, and it also needs to see the real Map. The same problem shows up with classes that use #private fields: when a method is called through a proxy, reading this.#field throws a TypeError.

There are rules a proxy can't break. The engine won't let traps give answers that contradict the target; these rules are called invariants. For example, if the target has a non-writable, non-configurable property, the get trap must return its actual value. The Object.freeze we saw earlier produces exactly that kind of property:

const constants = Object.freeze({ pi: 3.14 });
const liar = new Proxy(constants, { get: () => 42 });

try {
  liar.pi;
} catch (e) {
  console.log(e.message); // a TypeError saying the trap broke the rule
}

A proxy is shallow. In the reactive example, if you write state.address.city = "Izmir", the outer set trap doesn't run; that line reads address and then writes to a plain object. For nested objects you need to wrap the inner object in a proxy inside the get trap as well. Finally, code that changes the target directly never passes through any trap. If you're using a proxy, hand out a reference to the proxy, not to the target.

Performance: The Cost of a Proxy

Every trap is a function call, and the engine can't always apply the fast paths it uses for plain objects to a proxy. Even an empty handler makes a noticeable difference. Let's measure roughly with performance.now(), which we'll look at in detail in a later post:

const array = Array.from({ length: 1_000_000 }, (_, i) => i);
const wrapped = new Proxy(array, {});

function sum(a) {
  let s = 0;
  for (let i = 0; i < a.length; i++) s += a[i];
  return s;
}

let t0 = performance.now();
sum(array);
console.log("plain array:", (performance.now() - t0).toFixed(1), "ms");

t0 = performance.now();
sum(wrapped);
console.log("proxy:", (performance.now() - t0).toFixed(1), "ms");

The numbers vary from machine to machine, but the proxied version stays clearly slower. So use proxies at the edges: settings objects, the surface a module exposes, checks you run during development. Don't wrap data structures that get touched millions of times per second.

One last tool: Proxy.revocable. It creates a proxy that can be switched off and returns { proxy, revoke }. After you call revoke(), every operation on the proxy throws a TypeError. It's handy when you want to give temporary access to an object and cut it off later.

A Small Experiment

★☆☆ Write a fromEnd(array) function that wraps an array so that p[-1] returns the last element and p[-2] the one before it. Hint: the key that reaches the trap is always a string; convert it with Number(key).

★★☆ Predict the output: const p = new Proxy({}, { set: () => false }); p.x = 1; console.log(p.x); Run it in the console first, then put the same code inside a <script type="module"> in an HTML file. Why are the two results different?

★★★ In the reactive example, attach a handler to the like button that changes both state.likes and state.name at once, and watch render run twice. Using the queueMicrotask we saw earlier, write a version that collects the changes and renders only once.

In this post we saw that a proxy keeps holding on to its target, and that in the reactive example the event listeners keep state alive. So when does an object stay in memory, and when does it get cleaned up? In the next post we'll look at how garbage collection works, the most common memory leaks, and WeakMap and WeakRef.