The Proxy pattern — or how a pharmacy counter decides what you actually walk out with
You don't reach behind a pharmacy counter — you name what you want, and the person standing there decides whether you get it, get the generic instead, or get told you'll need a prescription first. JavaScript's Proxy does the same job for objects: sit in front of the real thing, and decide what each caller actually gets to see or change. Here it is, in one small, real example.
The issue
A pharmacy counter doesn't stock anything itself. It doesn't need to — it just sits between you and the shelves, and decides, per request, what actually comes back: the thing you asked for, the generic instead, or "you'll need a prescription for that one." And you never get to walk behind it and rearrange the stock yourself. That's the whole idea behind JavaScript's Proxy: wrap the real object, intercept its get and set, and decide right there what each caller is actually allowed to do.
Say your product has two plans — Free and Pro — and one dashboard object that already has every number on it, because it's simpler to compute them all in one place than to maintain two versions of the same query. The problem shows up right after: whoever renders the dashboard now has to remember, everywhere, which fields a Free user isn't supposed to see.
The data
Nothing exotic — one plain object, computed once, with a couple of fields your Pro customers are paying for and one field that shouldn't be touched from the UI at all.
const analyticsData = {
revenue: 48200,
activeUsers: 1310,
churnRate: 0.021,
aiForecast: "next 30 days: revenue up 6-9%", // Pro-only
apiRateLimit: 5000, // internal — shouldn't be editable from the UI at all, and this is the max limit
};Handing out the object directly — two problems
The dashboard component and the billing page both just read straight off it:
// dashboard.js
render(analyticsData.aiForecast); // shows the forecast to EVERY plan
// billing.js
analyticsData.apiRateLimit = 999999; // maybe a bug, say the max limit was 5000, nothing stops this — the limit's goneAnd no, declaring it const doesn't save you. const freezes the name, not the object — analyticsData = somethingElse throws, but writing to a property of the object it points at is completely legal. That line above goes through exactly the same whether you wrote const or let.
Worth being precise about who's doing this, too: it isn't a user poking at the console. analyticsData lives in module scope, so it isn't reachable from devtools — you'd get a ReferenceError. The culprit is your own code. Another module, a teammate's feature, a call site written months before that field was ever meant to be off-limits.
Both problems are the same shape: the object doesn't know it has anything to protect, so nothing does. Enforcing "Free users don't see this" or "this field isn't editable" means adding a check to every single place that touches analyticsData, forever, and hoping nobody forgets one.
Fix: gate it in the get function
Wrap the object once. Your get function runs on every property read, so it's the one place that decides what a given caller actually gets back:
function guardedDashboard(data, plan) {
const premiumFields = new Set(["aiForecast"]);
return new Proxy(data, {
get(target, prop, receiver) {
if (plan === "free" && premiumFields.has(prop)) {
return "Upgrade to Pro to see this";
}
return Reflect.get(target, prop, receiver);
},
});
}dashboard.js never has to know which plan it's rendering for — it just reads a property, same as before:
const dashboard = guardedDashboard(analyticsData, currentUser.plan);
dashboard.revenue; // 48200 — passes straight through
dashboard.aiForecast; // "Upgrade to Pro to see this" for free, the real string for proWhat Reflect is doing in there
One thing I skipped past: Reflect. It's a built-in object holding the default behaviours — one method per operation, each named after the function you write. You write get, you call Reflect.get. So that last line just means: no special case here, do the normal thing.
You could write target[prop] instead. For plain values like revenue, it's the same. It breaks the moment a property is a getter — and that's what the third argument, receiver, is for:
const analyticsData = {
aiForecast: "next 30 days: revenue up 6-9%",
get summary() {
return "AI says: " + this.aiForecast; // reads another field on itself
},
};
// get(target, prop) { return target[prop] }
dashboard.summary; // "AI says: next 30 days: revenue up 6-9%" <- leaked
// get(target, prop, receiver) { return Reflect.get(target, prop, receiver) }
dashboard.summary; // "AI says: Upgrade to Pro to see this" <- gate heldIt comes down to what this means inside the getter. With target[prop], the getter runs on the real object, so this.aiForecast reads the raw value and skips your check. receiver is the proxy, and Reflect.get passes it in as this — so that inner read goes back through the counter too.
Reflect.set is simpler: your set function has to return a boolean for whether the write worked, and returning false throws in strict mode. Reflect.set returns the right one. Write target[prop] = value and you're left hardcoding return true and hoping.
Locking a field for everyone: the set function
apiRateLimit needs a stronger rule — nobody outside a specific admin tool should ever be allowed to change it, plan or no plan. This is the other half of the counter: not what you're handed, but the shelves you don't get to reach. Your set function runs on every write, so it can just refuse:
function guardedDashboard(data, plan) {
const premiumFields = new Set(["aiForecast"]);
const lockedFields = new Set(["apiRateLimit"]);
return new Proxy(data, {
get(target, prop, receiver) {
if (plan === "free" && premiumFields.has(prop)) {
return "Upgrade to Pro to see this";
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (lockedFields.has(prop)) {
console.warn("blocked write to locked field:", prop);
return true; // ignored, not thrown — the caller's code keeps running
}
return Reflect.set(target, prop, value, receiver);
},
});
}Where a Proxy can bite you
A Proxy isn't validation, and it isn't security — it's a single, inspectable place to say "here's what this caller actually gets," instead of scattering that decision across every component that touches the object. Reach for it when the gate belongs to the data itself, and keep real enforcement — the part a user can't get around by opening devtools — on the server, same as always.
get and set are the two you'll reach for most, but there are others — has, deleteProperty, and a dozen more. One heads-up before you go looking: the docs call these functions "traps." Same things, scarier name. For the full list plus more on the Proxy/Reflect API, patterns.dev's Proxy Pattern writeup is a good next stop.