Proxy and Reflect

Most objects you deal with are passive. You read a property, you get the stored value. You write one, it lands. A Proxy slips a programmable layer between the code doing the reading and writing and the object underneath. Every fundamental operation — reading a property, writing one, calling the object as a function, running delete on it — can be caught by a function you supply, which either handles the operation itself or waves it through to the real object.

That interception happens at a very low level, below push, below for..in, below the dot operator itself. It’s the machinery behind reactive frameworks, mocking libraries, permission wrappers, and observable data. This article walks through what you can trap and how to do it cleanly.

Proxy

The constructor takes two arguments:

let proxy = new Proxy(target, handler)
  • target — the object you’re wrapping. Anything works here, including arrays, functions, and other proxies.
  • handler — the configuration object. Its methods are called traps, and each one intercepts a specific kind of operation. A get trap catches property reads, a set trap catches writes, and so on.

When you perform an operation on proxy, JavaScript looks for a matching trap in handler. If one exists, it runs and decides what happens. If not, the operation passes straight through to target.

Start with the simplest possible case — a handler with no traps at all:

let target = {};
let proxy = new Proxy(target, {}); // empty handler

proxy.test = 5; // writing to proxy (1)
alert(target.test); // 5, the property appeared in target!

alert(proxy.test); // 5, we can read it from proxy too (2)

for(let key in proxy) alert(key); // test, iteration works (3)

With no traps, every operation on proxy is forwarded to target:

  1. Writing proxy.test = 5 sets the value on target.
  2. Reading proxy.test returns the value straight from target.
  3. Iterating over proxy walks target’s keys.

An empty-handler proxy is a see-through pane. Operations pass through it as if it weren’t there.

your code
→ proxy.test →
Proxy
→ no trap →
target
your code
→ proxy.test →
Proxy
→ get trap runs →
whatever you return
With no traps, the proxy forwards every operation to the target; adding a trap lets you intercept the matching operation

A Proxy is what the specification calls an exotic object. It carries no properties of its own. With an empty handler it just relays everything to target. To make it do something interesting, you add traps.

What can you intercept?

For most operations on objects, the specification defines an internal method describing how the operation works at the lowest level. Reading a property runs [[Get]]. Writing one runs [[Set]]. These names live only in the spec — you can’t call obj.[[Get]] in your code. But proxy traps hook directly into these internal methods.

Every internal method has a matching trap name. Add a method by that name to your handler, and you intercept the operation:

Internal Method Handler Method Triggers when…
[[Get]] get reading a property
[[Set]] set writing to a property
[[HasProperty]] has in operator
[[Delete]] deleteProperty delete operator
[[Call]] apply function call
[[Construct]] construct new operator
[[GetPrototypeOf]] getPrototypeOf Object.getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf Object.setPrototypeOf
[[IsExtensible]] isExtensible Object.isExtensible
[[PreventExtensions]] preventExtensions Object.preventExtensions
[[DefineOwnProperty]] defineProperty Object.defineProperty, Object.defineProperties
[[GetOwnProperty]] getOwnPropertyDescriptor Object.getOwnPropertyDescriptor, for..in, Object.keys/values/entries
[[OwnPropertyKeys]] ownKeys Object.getOwnPropertyNames, Object.getOwnPropertySymbols, for..in, Object.keys/values/entries

Now for the practical patterns.

Default value with the “get” trap

Reading and writing are the two operations you’ll trap most often. Start with reading.

To catch reads, give the handler a get(target, property, receiver) method. It fires whenever a property is read, with three arguments:

  • target — the wrapped object, the first argument you passed to new Proxy.
  • property — the name of the property being read.
  • receiver — matters when the property is a getter. It’s the object used as this inside that getter, usually the proxy itself (or something that inherits from it). You’ll see why this argument earns its place later on; ignore it for now.

Here’s a classic use: an array that hands back 0 for indexes it doesn’t have, instead of the usual undefined.

let scores = [90, 80, 70];

scores = new Proxy(scores, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // default value
    }
  }
});

alert( scores[1] ); // 80
alert( scores[123] ); // 0 (no such item)

The trap checks whether the property exists on the target. If it does, return it untouched. If not, return the fallback.

read scores[1]
→ prop in target? yes
80
read scores[123]
→ prop in target? no
0 (default)
The get trap decides per-read: existing keys pass through, missing ones get a default

The same pattern powers any “fallback” logic. Take a glossary of chat abbreviations:

let glossary = {
  'brb': 'be right back',
  'ttyl': 'talk to you later'
};

alert( glossary['brb'] ); // be right back
alert( glossary['idk'] ); // undefined

Asking for a term that isn’t there gives undefined. For a glossary, echoing the original term back unexpanded is usually more useful than a blank. Wrap the glossary so a missing key returns the term itself:

let glossary = {
  'brb': 'be right back',
  'ttyl': 'talk to you later'
};

glossary = new Proxy(glossary, {
  get(target, term) { // intercept reading a property from glossary
    if (term in target) { // if we have it in the glossary
      return target[term]; // return the expansion
    } else {
      // otherwise, return the unexpanded term
      return term;
    }
  }
});

// Look up arbitrary terms in the glossary!
// At worst, they're left as-is.
alert( glossary['brb'] ); // be right back
alert( glossary['on my way']); // on my way (no expansion)

Type any term below. Known terms come back expanded; anything the glossary is missing is echoed back untouched, straight from the get trap. Edit the code to add your own entries.

interactiveA glossary that never returns undefined

Validation with the “set” trap

Suppose you want an array that accepts numbers only. Push anything else and it should throw.

The set trap catches writes. Its signature:

set(target, property, value, receiver)

  • target — the wrapped object.
  • property — the property name being written.
  • value — the value being assigned.
  • receiver — as with get, only relevant for setter properties.

One rule matters here: set must return true when the write succeeds. Return false (or forget to return anything) and JavaScript throws a TypeError.

let numbers = [];

numbers = new Proxy(numbers, { // (*)
  set(target, prop, val) { // to intercept property writing
    if (typeof val == 'number') {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  }
});

numbers.push(1); // added successfully
numbers.push(2); // added successfully
alert("Length is: " + numbers.length); // 2

numbers.push("test"); // TypeError ('set' on proxy returned false)

alert("This line is never reached (error in the line above)");

Try it below. Numbers are appended and length climbs; anything non-numeric is rejected by the trap, which returns false and triggers a TypeError that the demo catches and reports.

interactiveAn array that accepts numbers only

The array’s built-in behavior keeps working. push still appends, and length still updates on its own. You didn’t have to override push, unshift, or any of the mutating methods, because under the hood they all write through [[Set]] — the exact operation your trap intercepts. One trap covers every path that adds a value.

Iteration with “ownKeys” and “getOwnPropertyDescriptor”

Object.keys, for..in, and most other property-enumerating tools start by asking the object for its list of keys. That request runs the [[OwnPropertyKeys]] internal method, which the ownKeys trap intercepts.

Each of these methods filters that list differently:

  • Object.getOwnPropertyNames(obj) returns the non-symbol keys.
  • Object.getOwnPropertySymbols(obj) returns the symbol keys.
  • Object.keys/values() return non-symbol keys (and their values) that carry the enumerable flag — property flags are covered in Property flags and descriptors.
  • for..in loops non-symbol enumerable keys, plus inherited ones from the prototype chain.

However they filter, they all begin with the list ownKeys provides.

Here, ownKeys hides any key starting with an underscore from for..in, Object.keys, and Object.values:

let user = {
  name: "Maya",
  age: 30,
  _password: "***"
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "ownKeys" filters out _password
for(let key in user) alert(key); // name, then: age

// same effect on these methods:
alert( Object.keys(user) ); // name,age
alert( Object.values(user) ); // Maya,30

That works because every key you return is a real, enumerable property of the target. But there’s a catch when the keys you return don’t exist on the object:

let user = { };

user = new Proxy(user, {
  ownKeys(target) {
    return ['a', 'b', 'c'];
  }
});

alert( Object.keys(user) ); // <empty>

Nothing comes out. The reason: Object.keys only reports properties that carry the enumerable flag. To read that flag, it calls [[GetOwnProperty]] on each key to fetch its descriptor. Since these keys don’t exist on the target, their descriptors come back empty — no enumerable flag, so each key is dropped.

To make Object.keys list a key, it must either exist on the object as an enumerable property, or you intercept [[GetOwnProperty]] (the getOwnPropertyDescriptor trap) and hand back a descriptor with enumerable: true:

let user = { };

user = new Proxy(user, {
  ownKeys(target) { // called once to get a list of properties
    return ['a', 'b', 'c'];
  },

  getOwnPropertyDescriptor(target, prop) { // called for every property
    return {
      enumerable: true,
      configurable: true
      /* ...other flags, probably "value:..." */
    };
  }

});

alert( Object.keys(user) ); // a, b, c
ownKeys
→ [‘a’,‘b’,‘c’]
then for each key
getOwnPropertyDescriptor(key)
enumerable? →
kept
Object.keys collects the ownKeys list, then checks each key's descriptor for the enumerable flag

Note the scope of this: you only need getOwnPropertyDescriptor when the key you’re advertising is absent from the real object.

Protected properties with “deleteProperty” and friends

A common convention marks properties with a leading underscore to signal “internal — don’t touch from outside.” It’s only a convention, though. Nothing stops outside code from reaching in:

let user = {
  name: "Maya",
  _password: "secret"
};

alert(user._password); // secret

You can turn that convention into a hard rule with a proxy. To seal off every underscore-prefixed property, you need four traps working together:

  • get — throw when someone reads such a property,
  • set — throw when someone writes it,
  • deleteProperty — throw when someone deletes it,
  • ownKeys — hide it from for..in and Object.keys.
let user = {
  name: "Maya",
  _password: "***"
};

user = new Proxy(user, {
  get(target, prop) {
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    }
    let value = target[prop];
    return (typeof value === 'function') ? value.bind(target) : value; // (*)
  },
  set(target, prop, val) { // to intercept property writing
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      target[prop] = val;
      return true;
    }
  },
  deleteProperty(target, prop) { // to intercept property deletion
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      delete target[prop];
      return true;
    }
  },
  ownKeys(target) { // to intercept property list
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "get" doesn't allow to read _password
try {
  alert(user._password); // Error: Access denied
} catch(e) { alert(e.message); }

// "set" doesn't allow to write _password
try {
  user._password = "test"; // Error: Access denied
} catch(e) { alert(e.message); }

// "deleteProperty" doesn't allow to delete _password
try {
  delete user._password; // Error: Access denied
} catch(e) { alert(e.message); }

// "ownKeys" filters out _password
for(let key in user) alert(key); // name

The interesting line is (*) in the get trap:

get(target, prop) {
  // ...
  let value = target[prop];
  return (typeof value === 'function') ? value.bind(target) : value; // (*)
}

Why bind function-valued properties to target?

Consider a method on the object that legitimately needs _password:

user = {
  // ...
  checkPassword(value) {
    // object method must be able to read _password
    return value === this._password;
  }
}

When you call user.checkPassword(...), the object before the dot becomes this. But that object is the proxy. So inside the method, this._password is a read through the proxy — which fires the get trap, sees the underscore, and throws. The method can’t do its job.

Binding fixes it. Line (*) returns methods bound to the original target, so their this is the raw object, and their internal reads bypass the traps entirely.

This works, but it’s a compromise. Once a method is bound to the unproxied object, the method could hand that raw object off to code elsewhere, and now you have two references floating around — one guarded, one not. Layer several proxies on the same object and it gets murkier still. Reach for this pattern deliberately, not by default.

“In range” with the “has” trap

Here’s a range object:

let range = {
  start: 1,
  end: 10
};

Wouldn’t it be nice to write 5 in range and have it mean “is 5 between start and end”? The in operator runs [[HasProperty]], which the has trap intercepts.

has(target, property)

  • target — the wrapped object.
  • property — the value being tested with in.
let range = {
  start: 1,
  end: 10
};

range = new Proxy(range, {
  has(target, prop) {
    return prop >= target.start && prop <= target.end;
  }
});

alert(5 in range); // true
alert(50 in range); // false

A few lines of syntactic sugar, and in now checks membership in a numeric interval rather than the presence of a key. Test different numbers against the range below:

interactiveUsing 'in' to test a numeric range

Wrapping functions: “apply”

A proxy can wrap a function, not just a plain object. Functions are objects in JavaScript, so new Proxy(fn, handler) works — and the apply trap catches the call.

apply(target, thisArg, args)

  • target — the wrapped function,
  • thisArg — the this value for the call,
  • args — an array of the arguments.

Recall the delay(f, ms) decorator from Decorators and forwarding, call/apply. It returns a version of f whose calls are postponed by ms milliseconds. The original, function-based version:

function delay(f, ms) {
  // return a wrapper that passes the call to f after the timeout
  return function() { // (*)
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function greet(name) {
  alert(`Welcome, ${name}!`);
}

// after this wrapping, calls to greet will be delayed for 3 seconds
greet = delay(greet, 3000);

greet("Maya"); // Welcome, Maya! (after 3 seconds)

That wrapper delays calls fine. But it’s a different function from the original, and it forwards nothing except the call. Any property of the original — name, length, whatever else — is lost:

function delay(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function greet(name) {
  alert(`Welcome, ${name}!`);
}

alert(greet.length); // 1 (function length is the arguments count in its declaration)

greet = delay(greet, 3000);

alert(greet.length); // 0 (in the wrapper declaration, there are zero arguments)

The wrapper declares zero parameters, so greet.length now reports 0. A proxy avoids that, because a proxy forwards every operation to the target, including the read of length:

function delay(f, ms) {
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    }
  });
}

function greet(name) {
  alert(`Welcome, ${name}!`);
}

greet = delay(greet, 3000);

alert(greet.length); // 1 (*) proxy forwards "get length" operation to the target

greet("Maya"); // Welcome, Maya! (after 3 seconds)

Same delayed behavior, but now the wrapper is transparent for everything except the call itself. Reading greet.length at (*) reports 1, exactly as the original function does. The apply trap handles the call; all other operations flow through to f.

wrapper function
call ✓
.length ✗
.name ✗
proxy (apply trap)
call ✓
.length ✓
.name ✓
A plain wrapper function only forwards the call; a proxy forwards the call AND every other operation to the target function

Every other trap follows the same shape as the ones above: name the method, receive the operation’s details, decide what to do.

Reflect

Reflect is a built-in object that pairs with Proxy. Its whole purpose is to make forwarding operations painless.

Remember that internal methods like [[Get]] and [[Set]] can’t be called by name. Reflect is the loophole: it exposes them as ordinary functions, each a thin wrapper around one internal method.

Operations and their Reflect equivalents:

Operation Reflect call Internal method
obj[prop] Reflect.get(obj, prop) [[Get]]
obj[prop] = value Reflect.set(obj, prop, value) [[Set]]
delete obj[prop] Reflect.deleteProperty(obj, prop) [[Delete]]
new F(value) Reflect.construct(F, value) [[Construct]]

For example:

let user = {};

Reflect.set(user, 'name', 'Maya');

alert(user.name); // Maya

Being able to run operators like new and delete as plain functions is handy on its own. But the key point for proxies is this:

Every internal method that a Proxy can trap has a matching Reflect method with the same name and the same arguments as the trap.

So when a trap wants to say “just do the normal thing,” it calls Reflect.<sameName> with the arguments it received. Here, get and set log a message and then forward the operation unchanged:

let user = {
  name: "Maya",
};

user = new Proxy(user, {
  get(target, prop, receiver) {
    alert(`GET ${prop}`);
    return Reflect.get(target, prop, receiver); // (1)
  },
  set(target, prop, val, receiver) {
    alert(`SET ${prop}=${val}`);
    return Reflect.set(target, prop, val, receiver); // (2)
  }
});

let name = user.name; // shows "GET name"
user.name = "Raj"; // shows "SET name=Raj"
  • Reflect.get reads the property.
  • Reflect.set writes the property and returns true on success, false on failure — which is exactly what the set trap is required to return, so returning its result directly satisfies the invariant for free.

You could forward reads with target[prop] and skip Reflect entirely. In many cases that’s equivalent. But there’s a subtle situation where it isn’t.

Proxying a getter

Here’s the case that shows why Reflect.get is the safer choice, and what the receiver argument is actually for.

Start with a defaults object that has a _mode and a getter named mode, wrapped in a transparent proxy:

let defaults = {
  _mode: "auto",
  get mode() {
    return this._mode;
  }
};

let defaultsProxy = new Proxy(defaults, {
  get(target, prop, receiver) {
    return target[prop];
  }
});

alert(defaultsProxy.mode); // auto

The trap returns target[prop] and does nothing else. Reading defaultsProxy.mode gives "auto". Fine so far.

Now add inheritance. Make a panel object whose prototype is the proxy, with its own _mode:

let defaults = {
  _mode: "auto",
  get mode() {
    return this._mode;
  }
};

let defaultsProxy = new Proxy(defaults, {
  get(target, prop, receiver) {
    return target[prop]; // (*) target = defaults
  }
});

let panel = {
  __proto__: defaultsProxy,
  _mode: "dark"
};

// Expected: dark
alert(panel.mode); // outputs: auto (?!?)

panel.mode should be "dark" — but it comes out "auto". And if you delete the proxy and let panel inherit from defaults directly, it works correctly. So the proxy is the culprit, specifically line (*).

Trace what happens:

  1. panel.modepanel has no own mode, so lookup climbs the prototype chain.
  2. The prototype is defaultsProxy. Its get trap fires.
  3. The trap runs target[prop], which is defaults.mode. That triggers the getter — and because the getter is invoked on target, its this is defaults. So this._mode reads defaults._mode, which is "auto".

The getter ran against the wrong object. It should have run with this set to panel, the object the read actually started from.

panel.mode
→ trap: target[prop] → getter runs with
this = defaults
“auto” ✗
panel.mode
→ trap: Reflect.get(…,receiver) → getter runs with
this = panel
“dark” ✓
target[prop] runs the getter with this = target (defaults); Reflect.get runs it with this = receiver (panel)

That’s what receiver carries: the correct this for a getter — panel in this case. You can’t pass it with call or apply, because a getter isn’t called, it’s accessed. Reflect.get is the tool that threads receiver through to the getter:

let defaults = {
  _mode: "auto",
  get mode() {
    return this._mode;
  }
};

let defaultsProxy = new Proxy(defaults, {
  get(target, prop, receiver) { // receiver = panel
    return Reflect.get(target, prop, receiver); // (*)
  }
});

let panel = {
  __proto__: defaultsProxy,
  _mode: "dark"
};

alert(panel.mode); // dark

Now the getter runs with this set to receiver (that is, panel), and the read returns "dark".

Flip the trap between the two forwarding styles below and watch panel.mode change. Only Reflect.get threads receiver through, so only it reports "dark".

interactiveWhy the getter needs receiver

Since the trap’s parameters line up one-for-one with Reflect.get’s arguments, you can shorten it:

get(target, prop, receiver) {
  return Reflect.get(...arguments);
}

Reflect methods were named identically to the traps and take identical arguments on purpose. return Reflect.<method>(...arguments) is the reliable way to forward an operation without forgetting anything — the receiver, the return value, all of it.

Proxy limitations

Proxies reach deep into how objects behave, but they can’t wrap everything cleanly. A few sharp edges.

Built-in objects: internal slots

Built-ins like Map, Set, Date, and Promise store their data in internal slots — reserved, spec-only storage that isn’t a normal property. Map, for instance, keeps its entries in an internal slot called [[MapData]]. Its methods reach that slot directly, not through [[Get]] or [[Set]], so a proxy has no chance to intercept the access.

Why does that matter if the slots are internal? Because a proxy doesn’t have them. Wrap a Map and its methods can’t find their own data:

let map = new Map();

let proxy = new Proxy(map, {});

proxy.set('test', 1); // Error

Map.prototype.set runs with this = proxy and tries to read this.[[MapData]]. The proxy has no such slot, so it throws.

The fix is to bind the built-in methods back to the real object:

let map = new Map();

let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

proxy.set('test', 1);
alert(proxy.get('test')); // 1 (works!)

Now reading proxy.set returns map.set bound to map itself. When it runs, this is the real map, its [[MapData]] slot is right where it expects, and the call succeeds.

Private fields

Private class fields hit the same wall, for the same reason. This getPin() reads #pin and breaks once the instance is proxied:

class Locker {
  #pin = "0000";

  getPin() {
    return this.#pin;
  }
}

let locker = new Locker();

locker = new Proxy(locker, {});

alert(locker.getPin()); // Error

Private fields are implemented with internal slots too, and access to them doesn’t route through [[Get]]/[[Set]]. Inside getPin(), this is the proxy, which lacks the slot holding #pin.

The same binding trick restores it:

class Locker {
  #pin = "0000";

  getPin() {
    return this.#pin;
  }
}

let locker = new Locker();

locker = new Proxy(locker, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

alert(locker.getPin()); // 0000

The same caveat applies as before: binding leaks the raw object into the method, which can then pass it along and bypass whatever the proxy was enforcing.

Proxy is not the target

A proxy and its target are two distinct objects. Obvious, but it has teeth. If the original object is stored somewhere as a key or a member, the proxy won’t match it:

let allMembers = new Set();

class Member {
  constructor(name) {
    this.name = name;
    allMembers.add(this);
  }
}

let member = new Member("Maya");

alert(allMembers.has(member)); // true

member = new Proxy(member, {});

alert(allMembers.has(member)); // false

The set holds the original object. After member is reassigned to a proxy, allMembers.has(member) asks about the proxy, which was never added — so it’s false.

Revocable proxies

A revocable proxy can be switched off. Picture handing out access to some resource, then wanting to cut that access at a moment of your choosing. Wrap the resource in a revocable proxy — no traps needed — and later revoke it.

The factory function returns both the proxy and a revoke function:

let {proxy, revoke} = Proxy.revocable(target, handler)

Example:

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

// pass the proxy somewhere instead of object...
alert(proxy.data); // Valuable data

// later in our code
revoke();

// the proxy isn't working any more (revoked)
alert(proxy.data); // Error

Calling revoke() severs the proxy’s internal link to the target. From then on, any operation on the proxy throws. The target itself stays intact — it’s just no longer reachable through this proxy.

revoke comes back separately from proxy on purpose. You can pass proxy off to other code while keeping revoke private in your own scope, so only you can pull the plug. If you’d rather keep them together, attach it: proxy.revoke = revoke.

Another option is a WeakMap keyed by the proxy, storing its revoke:

let revokes = new WeakMap();

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

revokes.set(proxy, revoke);

// ..somewhere else in our code..
revoke = revokes.get(proxy);
revoke();

alert(proxy.data); // Error (revoked)

A WeakMap rather than a Map matters here: it holds its keys weakly, so it won’t keep a proxy alive. Once nothing else references the proxy, garbage collection can reclaim it along with its stored revoke, which is useless without the proxy anyway.

References

Summary

A Proxy wraps an object and relays operations to it, trapping any it’s configured to intercept. It can wrap anything — plain objects, arrays, classes, functions.

let proxy = new Proxy(target, {
  /* traps */
});

Use proxy everywhere in place of target. The proxy owns no properties of its own; each operation runs its trap if one exists, otherwise it forwards to target.

Operations you can trap include:

  • Reading (get), writing (set), and deleting (deleteProperty) a property — even one that doesn’t exist.
  • Calling a function (apply).
  • Constructing with new (construct).
  • Many more — the full list is in the table near the top of this article and in the MDN docs.

That opens the door to virtual properties, default values, observable objects, function decorators, and much more. You can even stack several proxies on one object, each adding its own layer.

The Reflect API is Proxy’s companion. Every trap has a matching Reflect call with identical arguments — use it to forward operations correctly, receiver and return value included.

The limitations to keep in mind:

  • Built-in objects rely on internal slots that proxies can’t reach; bind their methods to the target as a workaround.
  • Private class fields use slots too, so proxied methods need the target as this to see them.
  • === object comparisons can’t be trapped.
  • Performance depends on the engine, but a read through even a trivial proxy generally costs several times more than a direct read. That only shows up on hot-path “bottleneck” objects in practice.