JavaScript Proxy and Reflect

A Proxy lets you wrap an object and intercept the fundamental operations performed on it — reading a property, assigning one, deleting it, checking existence with in, and more. Reflect is a companion built-in object that exposes those same fundamental operations as plain functions, so you can invoke the “default” behavior from inside a trap instead of reinventing it. Together they give JavaScript a clean, standardized way to do metaprogramming: validation, logging, access control, computed properties, and API mocking, all without touching the original object’s code.

Overview: How Proxy and Reflect Work

Every object in JavaScript has a set of internal methods the engine calls whenever you interact with it — [[Get]], [[Set]], [[HasProperty]], [[Delete]], [[OwnPropertyKeys]], and so on. Normally these internal methods run invisibly: obj.name triggers [[Get]], delete obj.name triggers [[Delete]], and you never see them directly.

A Proxy sits between your code and a target object, and lets you supply your own JavaScript functions — called traps — that run instead of those internal methods. When you write new Proxy(target, handler), the handler object’s methods (get, set, has, deleteProperty, and so on) become the traps. Any trap you omit falls back to the target’s own default behavior automatically, so a proxy only needs to define the traps it actually wants to customize.

Reflect exists because, before ES6, there was no built-in way to invoke those default internal behaviors programmatically — you’d have to fake them with things like target[prop] = value, which subtly differs from the engine’s real [[Set]] semantics (especially around inheritance and the receiver). Every Reflect method corresponds one-to-one with a Proxy trap and with an internal method, so Reflect.get(target, prop, receiver) performs exactly the default behavior that would have run if there were no trap at all. This is why almost every trap body ends with a call to the matching Reflect method: it forwards the operation to the target correctly, including for edge cases like inherited getters/setters and prototype chains.

Because the traps intercept operations at the lowest level of the language, a proxy is transparent to the rest of your code — anything that receives the proxy just uses normal syntax (proxy.x, proxy.x = 5, "x" in proxy, delete proxy.x) and never needs to know a Proxy is involved at all.

Syntax

const proxy = new Proxy(target, handler);
  • target — the object being wrapped (can be a plain object, array, function, or even another proxy).
  • handler — a plain object whose methods are the traps. Any trap not defined uses the target’s default behavior.
  • proxy — the object your code actually interacts with from now on; the original target is still usable directly, but changes through either one are visible through both, since the proxy does not copy the target.

The most commonly used traps, and their Reflect equivalents:

Trap (handler method) Triggered by Reflect equivalent
get Reading a property: proxy.x Reflect.get(target, prop, receiver)
set Assigning a property: proxy.x = 1 Reflect.set(target, prop, value, receiver)
has The in operator Reflect.has(target, prop)
deleteProperty delete proxy.x Reflect.deleteProperty(target, prop)
ownKeys Object.keys(), for...in, Object.getOwnPropertyNames() Reflect.ownKeys(target)
getOwnPropertyDescriptor Object.getOwnPropertyDescriptor() Reflect.getOwnPropertyDescriptor(target, prop)
defineProperty Object.defineProperty() Reflect.defineProperty(target, prop, descriptor)
apply Calling a function proxy: proxy() Reflect.apply(target, thisArg, args)
construct new proxy() Reflect.construct(target, args)
getPrototypeOf Object.getPrototypeOf() Reflect.getPrototypeOf(target)

Examples

Example 1: Logging every read and write

const user = {
  name: "Alice",
  age: 30
};

const handler = {
  get(target, prop, receiver) {
    console.log(`Getting property "${prop}"`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    console.log(`Setting property "${prop}" to ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const proxyUser = new Proxy(user, handler);

console.log(proxyUser.name);
proxyUser.age = 31;
console.log(proxyUser.age);

Output:

Getting property "name"
Alice
Setting property "age" to 31
Getting property "age"
31

Every read of proxyUser runs the get trap first, and every write runs the set trap first. Each trap logs a message, then calls the matching Reflect method to actually perform the read or write on the real user object. This pattern — log, then forward to Reflect — is the backbone of almost every practical proxy.

Example 2: Validating data before it’s written

function createValidatedAccount(initialBalance) {
  const account = { balance: initialBalance };

  return new Proxy(account, {
    set(target, prop, value) {
      if (prop === "balance") {
        if (typeof value !== "number" || value < 0) {
          throw new TypeError("Balance must be a non-negative number");
        }
      }
      target[prop] = value;
      return true;
    }
  });
}

const account = createValidatedAccount(100);
account.balance = 250;
console.log(account.balance);

try {
  account.balance = -50;
} catch (error) {
  console.log(error.message);
}

Output:

250
Balance must be a non-negative number

The set trap acts as a guard: every assignment to balance is checked before it's allowed through. Because the trap throws instead of writing when the value is invalid, invalid data never reaches the underlying object — there's no way to bypass the check by assigning directly, since the caller only ever holds a reference to the proxy.

Example 3: Hiding properties from enumeration and in

const rawConfig = {
  _secretToken: "abc123",
  apiUrl: "https://api.example.com",
  timeout: 5000
};

const config = new Proxy(rawConfig, {
  has(target, prop) {
    if (typeof prop === "string" && prop.startsWith("_")) {
      return false;
    }
    return Reflect.has(target, prop);
  },
  ownKeys(target) {
    return Reflect.ownKeys(target).filter(key => !String(key).startsWith("_"));
  },
  getOwnPropertyDescriptor(target, prop) {
    if (typeof prop === "string" && prop.startsWith("_")) {
      return undefined;
    }
    return Reflect.getOwnPropertyDescriptor(target, prop);
  },
  deleteProperty(target, prop) {
    console.log(`Deleting ${prop}`);
    return Reflect.deleteProperty(target, prop);
  }
});

console.log("_secretToken" in config);
console.log("apiUrl" in config);
console.log(Object.keys(config));
delete config.timeout;
console.log(Object.keys(config));

Output:

false
true
[ 'apiUrl', 'timeout' ]
Deleting timeout
[ 'apiUrl' ]

Three traps cooperate here: has makes the in operator lie about underscore-prefixed keys, ownKeys filters them out of Object.keys()/for...in, and getOwnPropertyDescriptor keeps the illusion consistent for direct descriptor lookups (a required invariant — ownKeys and getOwnPropertyDescriptor must agree). The underlying _secretToken is still there on rawConfig; the proxy just presents a filtered view of it.

Under the Hood: Function Proxies and Revocable Proxies

Proxies aren't limited to plain objects — a function can be proxied too, using the apply trap (for normal calls) and the construct trap (for new). And any proxy can be made revocable, meaning it can be permanently disabled after the fact.

function sum(a, b) {
  return a + b;
}

const handler = {
  apply(target, thisArg, args) {
    console.log(`Calling sum with arguments: ${args}`);
    return Reflect.apply(target, thisArg, args);
  }
};

const proxySum = new Proxy(sum, handler);
console.log(proxySum(2, 3));

const { proxy, revoke } = Proxy.revocable({ value: 42 }, {});
console.log(proxy.value);
revoke();

try {
  console.log(proxy.value);
} catch (error) {
  console.log(error instanceof TypeError);
}

Output:

Calling sum with arguments: 2,3
5
42
true

Step by step: calling proxySum(2, 3) triggers the apply trap (not get, since it's a function call, not a property read), which logs a message and forwards the call with Reflect.apply. Separately, Proxy.revocable(target, handler) returns { proxy, revoke } — the same kind of proxy you'd get from new Proxy(), plus a revoke function. Calling revoke() permanently disconnects the proxy from its target; every trap on it (even ones that were never defined) starts throwing TypeError forever after. This is the standard pattern for handing out access that you may need to shut off later, like a temporary API token or a sandboxed module reference.

Common Mistakes

Mistake 1: Referencing the proxy itself inside its own trap

const target = { value: 10 };
const proxy = new Proxy(target, {
  get(target, prop, receiver) {
    return proxy[prop];
  }
});

try {
  console.log(proxy.value);
} catch (error) {
  console.log(error instanceof RangeError);
}

Output:

true

Reading proxy[prop] from inside the get trap re-triggers the very same trap, forever, until the call stack overflows with a RangeError. Traps must operate on the target parameter (or forward through Reflect), never on the proxy variable itself. The fix:

const target = { value: 10 };
const proxy = new Proxy(target, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
  }
});

console.log(proxy.value);

Output:

10

Mistake 2: Forgetting to return true from a set trap

"use strict";

const target = { count: 0 };
const proxy = new Proxy(target, {
  set(target, prop, value) {
    target[prop] = value;
    // BUG: no return value, so the trap "fails" from the engine's point of view
  }
});

try {
  proxy.count = 5;
} catch (error) {
  console.log(error.message);
}

Output:

'set' on proxy: trap returned falsish for property 'count'

The set, deleteProperty, defineProperty, and preventExtensions traps must return a boolean indicating success. In strict mode (and inside classes and modules, which are always strict), a falsy return throws immediately. The fix is simply to return true:

"use strict";

const target = { count: 0 };
const proxy = new Proxy(target, {
  set(target, prop, value) {
    target[prop] = value;
    return true;
  }
});

proxy.count = 5;
console.log(target.count);

Output:

5

Best Practices

  • Always forward unhandled logic through the matching Reflect method (e.g. Reflect.get, Reflect.set) instead of hand-rolling it with target[prop]Reflect correctly preserves the receiver, which matters once inheritance or getters/setters are involved.
  • Only implement the traps you actually need. Every omitted trap already does the right default thing, so a validation proxy might only need a set trap.
  • Return the correct type from every trap: has/deleteProperty/set must return booleans, ownKeys must return an array of strings/symbols, and so on — mismatches throw TypeErrors.
  • Use Proxy.revocable() instead of a plain new Proxy() whenever you're handing a reference to code you don't fully trust, so you can cut off access later.
  • Avoid proxies on hot paths in performance-critical code — every trapped operation adds function-call overhead compared to touching a plain object directly.
  • Remember a WeakMap is often a simpler alternative to a proxy when all you need is to associate private data with an object, without intercepting operations.
  • When wrapping a class instance, be aware that methods called through the proxy receive the proxy as this (via receiver), which is usually what you want but can surprise code that expects the raw target.

Practice Exercises

  • Write a Proxy around a plain object that makes all of its property names case-insensitive — reading obj.Name should return the same value as obj.name.
  • Write a Proxy around an array that supports negative indices, so proxy[-1] returns the last element (hint: implement the get trap and check whether prop, converted to a number, is negative).
  • Write a read-only proxy factory, makeReadOnly(obj), whose set and deleteProperty traps always throw a TypeError instead of modifying the target.

Summary

  • Proxy wraps a target object and lets you intercept fundamental operations (get, set, has, delete, and more) with custom functions called traps.
  • Reflect provides the default implementation of each of those operations as a plain function, so traps can forward to it instead of duplicating engine behavior.
  • Any trap you don't define automatically falls back to the target's normal behavior.
  • Function targets can be proxied too, using the apply and construct traps.
  • Proxy.revocable() creates a proxy that can be permanently disabled later with its paired revoke() function.
  • Traps must return the correct type (often a boolean) or the engine throws a TypeError, especially in strict mode.
  • Common uses include validation, logging, access control, computed/virtual properties, and mocking APIs in tests.