Proxy & Reflect
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.
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);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:
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);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:
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);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:
| Trap | Runs for | Default with Reflect |
|---|---|---|
get | obj.key, obj[key] | Reflect.get(target, key, receiver) |
set | obj.key = value | Reflect.set(target, key, value, receiver) |
has | key in obj | Reflect.has(target, key) |
deleteProperty | delete obj.key | Reflect.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:
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));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:
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 TypeErrorThe 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:
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 redrawUpdate 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,MapandWeakMaptreat them as two different objects, so do not mix them in comparisons or lookups. - Built-in objects and private fields.
Map,Set,Dateand class instances with#privatefields keep their data in hidden places that only the real object has. Their methods expectthisto be that real object, so calling them through a plain proxy fails:
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}`);
}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 returnsundefined, which counts as failure and throws aTypeErrorin strict code. Return the result ofReflect.set(). - Using
target[key]with getters. Forward withReflect.get(target, key, receiver)sothisstays 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 asget,set,hasanddeletePropertyintercept operations.- Operations without a trap go straight to the target, and changes made through the proxy land in the target.
setanddeletePropertymust returntrueon success;falsecauses aTypeErrorin strict code.Reflectmethods perform the default behaviour; forward with them and passreceiver.Proxy.revocable()gives access that can be switched off withrevoke().- Proxies power reactivity in frameworks such as Vue, but they cost speed, are not
===to their target and break built-ins such asMap.
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
defaultsthrows anErrorwith the messageUnknown setting: <key>. key in settingsistruefor every key indefaults.
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
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);
}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?
Traps such as get and set live in the handler; operations without a trap go to the target.
2. What should a set trap return after a successful write?
Returning false or nothing signals failure, which throws a TypeError in strict mode code.
3. Why forward with Reflect.get(target, key, receiver) instead of target[key]?
receiver is normally the proxy, so this inside a getter keeps using the proxy's rules.
4. Why does new Proxy(new Map(), {}).get("x") throw a TypeError?
Built-ins such as Map, Set and Date, and classes with #private fields, reject a proxy as this.
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
