Proxy & Reflect

JavaScript · Lesson 52 of 61 8 min read Updated

Normally, reading student.marks or writing cart.price = 499 just reads or writes a property. A proxy lets you step in between: it wraps an object and runs your own function every time someone reads, writes, checks or deletes a property. Frameworks use this to update the screen automatically when data changes.

In this lesson you will create proxies that log, validate and supply default values, learn why the Reflect object belongs in every proxy, revoke a proxy, and see when a proxy is the wrong tool.

Your first proxy

new Proxy(target, handler) takes two objects. The target is the object being wrapped. The handler holds traps: methods named after the operation they intercept, such as get for reading and set for writing. Any operation without a trap goes straight to the target.

JavaScriptRun it yourself
const stock = { pens: 40, notebooks: 12 };

const logged = new Proxy(stock, {
  get(target, key, receiver) {
    console.log(`read ${String(key)}`);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`write ${String(key)} = ${value}`);
    return Reflect.set(target, key, value, receiver);
  },
});

logged.pens -= 5;
console.log(logged.notebooks);
console.log(stock.pens);
console.log(logged === stock);
Output
read pens
write pens = 35
read notebooks
12
35
false

logged.pens -= 5 is really a read followed by a write, so both traps run. The proxy stores nothing itself: the change lands in stock. But the proxy is a different object from its target, which is why the last line prints false.

Each trap receives the target and the property key. set also receives the new value. The last argument, receiver, is the object the code actually used, normally the proxy itself. String(key) is there because keys can also be symbols, and a symbol cannot go straight into a template literal.

Validation with the set trap

A set trap can refuse bad values before they ever reach the object. Here, marks must be whole numbers from 0 to 100:

JavaScriptRun it yourself
function withMarksCheck(target) {
  return new Proxy(target, {
    set(obj, key, value, receiver) {
      if (key === "marks" && !(Number.isInteger(value) && value >= 0 && value <= 100)) {
        throw new RangeError(`marks must be a whole number from 0 to 100, not ${value}`);
      }
      return Reflect.set(obj, key, value, receiver);
    },
  });
}

const student = withMarksCheck({ name: "Ananya", marks: 0 });

student.marks = 88;
console.log(student.name, student.marks);

for (const bad of [105, "ninety", 72.5]) {
  try {
    student.marks = bad;
  } catch (error) {
    console.log(error.message);
  }
}
console.log(student.marks);
student.city = "Kochi"; // other keys pass straight through
console.log(student.city);
Output
Ananya 88
marks must be a whole number from 0 to 100, not 105
marks must be a whole number from 0 to 100, not ninety
marks must be a whole number from 0 to 100, not 72.5
88
Kochi

A set trap must return true when the write succeeded. If it returns false, code in strict mode (which includes every module and class) gets a TypeError. Throwing your own error, as here, gives a clearer message. Note that the check protects only writes made through the proxy: code that still holds the original object can change it freely.

The has and deleteProperty traps

The has trap runs for the in operator, and deleteProperty runs for delete:

JavaScriptRun it yourself
const account = new Proxy({ id: 501, holder: "Kabir", _pin: "4821" }, {
  has(target, key) {
    if (typeof key === "string" && key.startsWith("_")) return false;
    return Reflect.has(target, key);
  },
  deleteProperty(target, key) {
    if (key === "id") {
      console.log("Refusing to delete id");
      return false;
    }
    return Reflect.deleteProperty(target, key);
  },
});

console.log("holder" in account, "_pin" in account);
console.log(account._pin);

delete account.holder;
console.log("holder" in account);

try {
  delete account.id;
} catch (error) {
  console.log(error.name);
}
console.log(account.id);
Output
true false
4821
false
Refusing to delete id
TypeError
501

Look at the second line of output. The has trap hides _pin from in, but account._pin still reads it, because reading uses the get trap. Each trap covers only its own operation, so a proxy like this is not a security feature. For data that must stay private, use private class fields such as #pin (see Classes).

Reflect: the default behaviour

Reflect is a built-in object with one method for each trap, and each method does exactly what JavaScript would do without a proxy:

TrapRuns forDefault with Reflect
getobj.key, obj[key]Reflect.get(target, key, receiver)
setobj.key = valueReflect.set(target, key, value, receiver)
haskey in objReflect.has(target, key)
deletePropertydelete obj.keyReflect.deleteProperty(target, key)

There are more traps, such as ownKeys for Object.keys() and apply for calling a function, and Reflect has a matching method for each. You could write target[key] instead of Reflect.get(), but two details make Reflect the safer habit. Its methods return true or false instead of throwing, which is exactly what traps must return. And they pass on the receiver, so getters run with the proxy as this. This example shows why that matters:

JavaScriptRun it yourself
const cart = {
  price: 1299,
  get finalPrice() {
    return this.price - this.discount; // discount has not been set
  },
};

const withoutReflect = new Proxy(cart, {
  get(target, key) {
    return key in target ? target[key] : 0; // missing keys default to 0
  },
});

const withReflect = new Proxy(cart, {
  get(target, key, receiver) {
    return key in target ? Reflect.get(target, key, receiver) : 0;
  },
});

console.log(withoutReflect.discount, withReflect.discount);
console.log(withoutReflect.finalPrice);
console.log(withReflect.finalPrice);

console.log(Reflect.ownKeys(cart));
console.log(Reflect.set(Object.freeze({ seats: 2 }), "seats", 3));
Output
0 0
NaN
1299
[ 'price', 'finalPrice' ]
false

Both proxies give missing keys a default of 0. But in the first one, target[key] runs the getter with this set to the plain cart, so this.discount skips the proxy and gives undefined, and 1299 - undefined is NaN. Reflect.get passes the proxy as this, so this.discount goes through the trap and gets 0. The last line shows Reflect.set returning false for a frozen object, where a normal assignment in strict code would throw.

Revocable proxies and reactivity

Proxy.revocable(target, handler) returns a proxy together with a revoke function. After revoke(), every operation on that proxy throws a TypeError, while the target itself is untouched. Use it to hand out access that must end, such as an object given to a plugin:

JavaScript
const paper = { subject: "Maths", questions: 30 };
const { proxy: access, revoke } = Proxy.revocable(paper, {});

console.log(access.subject); // Maths
revoke();                    // time is up
console.log(access.questions); // throws TypeError

The best-known real use of proxies is reactivity: the screen updates itself when data changes. Vue 3's reactive() wraps your state in a proxy whose get trap records which parts of the page read each property, and whose set trap redraws those parts after a change. Immer, a library for updating immutable data, also uses proxies. Here is the core idea in a few lines:

JavaScriptRun it yourself
function reactive(state, onChange) {
  return new Proxy(state, {
    set(target, key, value, receiver) {
      const old = target[key];
      const ok = Reflect.set(target, key, value, receiver);
      if (ok && old !== value) onChange(key, value);
      return ok;
    },
  });
}

const score = reactive({ runs: 0, wickets: 0 }, (key, value) => {
  console.log(`Update scoreboard: ${key} = ${value}`);
});

score.runs += 4;
score.runs += 6;
score.wickets++;
score.wickets = 1; // same value: nothing to redraw
Output
Update scoreboard: runs = 4
Update scoreboard: runs = 10
Update scoreboard: wickets = 1

The code that changes the score knows nothing about the scoreboard. It just assigns values, and the proxy reacts.

Cautions: speed and identity

Proxies are powerful, but they have real costs:

  • Speed. Every property access on a proxy calls a function. That is fine for app state, but avoid proxies around objects used millions of times in a tight loop.
  • Identity. A proxy is not its target. ===, Set, Map and WeakMap treat them as two different objects, so do not mix them in comparisons or lookups.
  • Built-in objects and private fields. Map, Set, Date and class instances with #private fields keep their data in hidden places that only the real object has. Their methods expect this to be that real object, so calling them through a plain proxy fails:
JavaScriptRun it yourself
const team = { name: "Rohtak Royals" };
const watched = new Proxy(team, {});
console.log(watched === team);
console.log(new Set([team]).has(watched));

const menu = new Proxy(new Map([["chai", 15]]), {});
try {
  menu.get("chai");
} catch (error) {
  console.log(`Map: ${error.name}`);
}

class Wallet {
  #balance = 500;
  get balance() {
    return this.#balance;
  }
}
const wallet = new Proxy(new Wallet(), {});
try {
  console.log(wallet.balance);
} catch (error) {
  console.log(`Wallet: ${error.name}`);
}
Output
false
false
Map: TypeError
Wallet: TypeError

Also remember that a proxy is shallow. score.players.push("Rohan") reads players through the proxy, but the push changes the inner array directly, so no set trap runs. Frameworks solve this by wrapping nested objects too.

Common mistakes

  • Forgetting to return from set. A trap that returns nothing returns undefined, which counts as failure and throws a TypeError in strict code. Return the result of Reflect.set().
  • Using target[key] with getters. Forward with Reflect.get(target, key, receiver) so this stays the proxy.
  • Changing the original object. Code that keeps a reference to the target skips every trap.
  • Logging the proxy itself while debugging. Consoles inspect objects in different ways and may or may not run your traps. Log single properties instead.

Key points

  • new Proxy(target, handler) wraps an object; traps such as get, set, has and deleteProperty intercept operations.
  • Operations without a trap go straight to the target, and changes made through the proxy land in the target.
  • set and deleteProperty must return true on success; false causes a TypeError in strict code.
  • Reflect methods perform the default behaviour; forward with them and pass receiver.
  • Proxy.revocable() gives access that can be switched off with revoke().
  • Proxies power reactivity in frameworks such as Vue, but they cost speed, are not === to their target and break built-ins such as Map.

Exercise

Write createSettings(defaults). It returns a proxy around an empty object with these rules:

  • Reading a setting that has not been set returns the value from defaults.
  • Setting a key that is not in defaults throws an Error with the message Unknown setting: <key>.
  • key in settings is true for every key in defaults.

Test it with { theme: "light", language: "en", fontSize: 16 }: change theme to "dark", print all three settings, test in, and try to set colour.

Show solution
JavaScript
function createSettings(defaults) {
  return new Proxy({}, {
    get(target, key, receiver) {
      return Reflect.has(target, key) ? Reflect.get(target, key, receiver) : defaults[key];
    },
    set(target, key, value, receiver) {
      if (!Object.hasOwn(defaults, key)) {
        throw new Error(`Unknown setting: ${String(key)}`);
      }
      return Reflect.set(target, key, value, receiver);
    },
    has(target, key) {
      return Object.hasOwn(defaults, key);
    },
  });
}

const settings = createSettings({ theme: "light", language: "en", fontSize: 16 });
settings.theme = "dark";
console.log(settings.theme, settings.language, settings.fontSize);
console.log("fontSize" in settings, "colour" in settings);

try {
  settings.colour = "blue";
} catch (error) {
  console.log(error.message);
}
Output
dark en 16
true false
Unknown setting: colour

The get trap falls back to defaults, the set trap guards the list of allowed keys with Object.hasOwn(), and has answers from defaults so in matches what the settings object allows.

Quick Quiz

1. In new Proxy(target, handler), what is the handler?

2. What should a set trap return after a successful write?

3. Why forward with Reflect.get(target, key, receiver) instead of target[key]?

4. Why does new Proxy(new Map(), {}).get("x") throw a TypeError?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call