Technology · JavaScript

Advanced

JavaScript Proxy and Reflect

Learn how Proxy traps and the Reflect API intercept and control object behavior in JavaScript.

TL;DR
  1. 01Wrap objects with Proxy to intercept reads, writes, and calls.
  2. 02Define traps like get and set to customize behavior.
  3. 03Use Reflect to forward default behavior inside trap handlers.

Creating a Proxy

  • Wrap any object with new Proxy(target, handler) to intercept operations performed on it.
    const target = { name: "Ada" };
    const proxy = new Proxy(target, {});
    proxy.name; // "Ada" — no traps defined, so default behavior applies
    
  • The handler object holds trap functions; any operation without a matching trap falls through to the target.
    const handler = {
      get(target, prop) {
        console.log(`reading ${prop}`);
        return target[prop];
      }
    };
    const proxy = new Proxy({ x: 1 }, handler);
    proxy.x; // logs "reading x", returns 1
    
  • A Proxy behaves like the original object externally, so existing code can use it without changes.
    const user = new Proxy({ name: "Ada" }, {});
    typeof user; // "object"
    JSON.stringify(user); // '{"name":"Ada"}'
    
  • Proxies can wrap functions too, since functions are objects in JavaScript.
    function greet(name) { return `Hi, ${name}`; }
    const proxied = new Proxy(greet, {
      apply(target, thisArg, args) {
        console.log("called with", args);
        return target(...args);
      }
    });
    proxied("Ada"); // logs "called with [\"Ada\"]", returns "Hi, Ada"
    
  • Once created, a Proxy's traps stay active for the lifetime of every interaction with that wrapper.
    const logged = new Proxy({}, {
      set(target, prop, value) {
        console.log(`${prop} = ${value}`);
        return Reflect.set(target, prop, value);
      }
    });
    logged.a = 1; // logs "a = 1"
    

Common Traps

  • The get trap intercepts property reads, including method calls and inherited properties.
    const proxy = new Proxy({ a: 1 }, {
      get(target, prop) {
        return prop in target ? target[prop] : `no ${String(prop)}`;
      }
    });
    proxy.a; // 1
    proxy.b; // "no b"
    
  • The set trap intercepts property writes and must return true to signal success.
    const proxy = new Proxy({}, {
      set(target, prop, value) {
        target[prop] = value;
        return true;
      }
    });
    proxy.x = 5; // triggers the trap
    
  • The has trap intercepts the in operator, useful for hiding certain properties from checks.
    const proxy = new Proxy({ secret: 1, open: 2 }, {
      has(target, prop) {
        return prop === "secret" ? false : prop in target;
      }
    });
    "secret" in proxy; // false
    "open" in proxy;   // true
    
  • The deleteProperty trap intercepts the delete operator on the object.
    const proxy = new Proxy({ locked: true }, {
      deleteProperty(target, prop) {
        if (prop === "locked") return false;
        delete target[prop];
        return true;
      }
    });
    delete proxy.locked; // blocked by the trap
    
  • The apply trap intercepts calls when the target itself is a function.
    function add(a, b) { return a + b; }
    const traced = new Proxy(add, {
      apply(target, thisArg, args) {
        console.log("args:", args);
        return Reflect.apply(target, thisArg, args);
      }
    });
    traced(2, 3); // logs "args: [2, 3]", returns 5
    

Using Reflect for Default Behavior

  • Reflect provides a method matching every Proxy trap, like Reflect.get() and Reflect.set().
    const proxy = new Proxy({ a: 1 }, {
      get(target, prop, receiver) {
        return Reflect.get(target, prop, receiver);
      }
    });
    proxy.a; // 1, identical to default behavior
    
  • Pass the receiver argument through to Reflect methods so getters keep the correct this binding.
    const target = {
      _value: 10,
      get value() { return this._value; }
    };
    const proxy = new Proxy(target, {
      get(target, prop, receiver) {
        return Reflect.get(target, prop, receiver);
      }
    });
    proxy.value; // 10, with correct this binding
    
  • Reflect methods return clear booleans for success or failure instead of throwing in some cases.
    const obj = Object.freeze({ a: 1 });
    Reflect.set(obj, "a", 2); // false, no exception thrown
    
  • Reflect.ownKeys() and Reflect.deleteProperty() mirror Object methods but work consistently inside traps.
    const proxy = new Proxy({ a: 1, b: 2 }, {
      ownKeys(target) {
        return Reflect.ownKeys(target);
      }
    });
    Object.keys(proxy); // ["a", "b"]
    
  • Combining Proxy with Reflect keeps trap code minimal — only override what truly needs custom logic.
    const handler = {
      get(target, prop, receiver) {
        console.log(`get ${String(prop)}`);
        return Reflect.get(target, prop, receiver);
      }
      // other traps fall back to default behavior automatically
    };
    

Practical Use Cases

  • Use a set trap to validate data before it's written, rejecting invalid values early.
    function createValidated(schema) {
      return new Proxy({}, {
        set(target, prop, value) {
          if (schema[prop] && typeof value !== schema[prop]) {
            throw new TypeError(`${String(prop)} must be ${schema[prop]}`);
          }
          return Reflect.set(target, prop, value);
        }
      });
    }
    const user = createValidated({ age: "number" });
    user.age = "old"; // throws TypeError
    
  • Use a get trap to log every property access, helpful for debugging unexpected reads.
    function withLogging(target) {
      return new Proxy(target, {
        get(target, prop, receiver) {
          console.log(`accessed ${String(prop)}`);
          return Reflect.get(target, prop, receiver);
        }
      });
    }
    
  • Use a get trap to supply default values for missing properties instead of returning undefined.
    function withDefault(target, defaultValue) {
      return new Proxy(target, {
        get(target, prop) {
          return prop in target ? target[prop] : defaultValue;
        }
      });
    }
    const counts = withDefault({}, 0);
    counts.missing; // 0
    
  • Build a simple reactive system by combining a set trap with a notification callback.
    function reactive(obj, onChange) {
      return new Proxy(obj, {
        set(target, prop, value) {
          const result = Reflect.set(target, prop, value);
          onChange(prop, value);
          return result;
        }
      });
    }
    const state = reactive({ count: 0 }, (k, v) => console.log(`${k} -> ${v}`));
    state.count = 1; // logs "count -> 1"
    
  • Use a Proxy with an apply trap to wrap functions for timing or retry logic without modifying the original.
    function withTiming(fn) {
      return new Proxy(fn, {
        apply(target, thisArg, args) {
          const start = performance.now();
          const result = Reflect.apply(target, thisArg, args);
          console.log(`took ${performance.now() - start}ms`);
          return result;
        }
      });
    }
    

Pitfalls to Avoid

  • A get trap that returns plain functions can break methods relying on this referring to the proxy or target.
    const target = {
      items: [1, 2, 3],
      getFirst() { return this.items[0]; }
    };
    const proxy = new Proxy(target, {
      get(target, prop) { return target[prop]; } // loses receiver binding
    });
    proxy.getFirst(); // works here, but breaks with inherited getters
    
  • Traps add overhead to every intercepted operation, so avoid proxies in tight, performance-critical loops.
    // Avoid wrapping objects accessed thousands of times per animation frame
    
  • A set trap must return true, or strict mode code throws a TypeError on assignment.
    "use strict";
    const broken = new Proxy({}, {
      set() { /* forgot to return true */ }
    });
    broken.a = 1; // throws TypeError in strict mode
    
  • Comparing a Proxy to its target with === returns false, since they are different object identities.
    const target = {};
    const proxy = new Proxy(target, {});
    proxy === target; // false
    
  • Some traps must satisfy invariants the engine enforces, like not reporting a non-configurable property as deletable.
    const target = {};
    Object.defineProperty(target, "locked", { configurable: false, value: 1 });
    const proxy = new Proxy(target, {
      deleteProperty() { return true; } // violates invariant, throws TypeError
    });
    delete proxy.locked; // TypeError
    
Tips
  1. 01Call the matching Reflect method inside every trap so default behavior still works for properties you don't customize.
  2. 02Use a Proxy for validation logic, like rejecting invalid property assignments before they ever reach the target object.
  3. 03Reach for Reflect.has() and Reflect.deleteProperty() over the in and delete operators when writing generic trap handlers.
Warnings
  1. 01Forgetting to bind this correctly inside a get trap can break methods relying on internal state of the target.
  2. 02Adding traps to every property access adds real overhead, so avoid wrapping objects accessed in tight performance-critical loops.
  3. 03A set trap that doesn't return true silently fails in non-strict mode but throws a TypeError in strict mode code.
FAQ
JavaScript Prototypal InheritanceJavaScript Symbols