TL;DR
Learn JavaScript Proxy and Reflect to intercept object operations. Build validation, logging, and reactive data patterns.
Key concepts
- JavaScript Proxy
- JavaScript Reflect
- JS metaprogramming
- Proxy handler traps
Proxy and Reflect
Every time you read a property, assign a value, or call a function in JavaScript, the engine performs a set of well-defined internal operations. Normally these are invisible. With Proxy, you can step in front of them — intercept the operation, inspect it, modify it, or block it entirely.
Reflect is the companion API. It gives you a clean way to forward those same operations after your interception logic runs, without reimplementing the engine's default behaviour yourself.
Together they unlock patterns that are impossible with ordinary objects: transparent validation, automatic logging, default property values, and the reactive data systems used by frameworks like Vue 3.
What Is a Proxy?
A Proxy wraps a target object and intercepts operations through a handler — a plain object whose methods are called traps. Each trap corresponds to a fundamental operation like get, set, has, or deleteProperty.
const user = { name: "Alice", age: 30 };
const proxy = new Proxy(user, {
get(target, key) {
console.log(`Reading: ${key}`);
return target[key];
},
set(target, key, value) {
console.log(`Writing: ${key} = ${value}`);
target[key] = value;
return true; // must return true to signal success
}
});
proxy.name; // Reading: name
proxy.age = 31; // Writing: age = 31
console.log(user); // { name: "Alice", age: 31 }
The original user object is untouched in terms of its interface — reads and writes still work — but the proxy intercepts every operation before it reaches the target.
The Reflect API
Before Reflect, forwarding an intercepted operation back to the default behaviour meant duplicating engine logic: target[key] = value, Object.defineProperty(target, key, descriptor), and so on. Each trap has slightly different semantics, making this error-prone.
Reflect solves this by exposing a method for every trap that does exactly what the default engine behaviour would do.
const handler = {
get(target, key, receiver) {
console.log(`get: ${key}`);
return Reflect.get(target, key, receiver); // forward cleanly
},
set(target, key, value, receiver) {
console.log(`set: ${key} = ${value}`);
return Reflect.set(target, key, value, receiver); // forward cleanly
}
};
const config = new Proxy({ debug: false, retries: 3 }, handler);
config.debug; // get: debug
config.retries = 5; // set: retries = 5
console.log(config.retries); // get: retries → 5
The receiver argument matters when dealing with inherited properties and getters — passing it through Reflect preserves the correct this binding automatically. Make it a habit to always forward it.
Write a diagnosis before editing, then run the corrected code until its output matches the expected output.
JavaScript Proxy with a recursive get trap
Debug
This Proxy get trap recurses until the stack overflows. Diagnose why, then fix it so each property read is logged once.
const target = { host: "localhost", port: 3000 };
const proxy = new Proxy(target, {
get(obj, key) {
console.log("read: " + key);
return proxy[key];
},
});
console.log(proxy.host + ":" + proxy.port);Expected output: read: host
read: port
localhost:3000
Practical Pattern: Schema Validation
One of the most useful applications is enforcing constraints on object mutations. Instead of scattering validation logic across your codebase, you can attach it once at the proxy boundary.
function createValidated(target, schema) {
return new Proxy(target, {
set(obj, key, value) {
const rule = schema[key];
if (!rule) throw new TypeError(`Unknown property: ${key}`);
if (!rule(value)) {
throw new RangeError(`Invalid value for ${key}: ${value}`);
}
return Reflect.set(obj, key, value);
}
});
}
const schema = {
name: (v) => typeof v === "string" && v.length > 0,
age: (v) => Number.isInteger(v) && v >= 0 && v <= 150,
};
const person = createValidated({}, schema);
person.name = "Bob"; // fine
person.age = 25; // fine
console.log(person.name, person.age); // Bob 25
try {
person.age = -5;
} catch (e) {
console.log(e.message); // Invalid value for age: -5
}
try {
person.email = "bob@example.com";
} catch (e) {
console.log(e.message); // Unknown property: email
}
Validation is now declarative and centralised. Adding a new field means extending the schema, not hunting through assignment sites.
Practical Pattern: Default Property Values
The get trap fires even when a property is undefined. That gives you a clean way to supply default values on read — similar in spirit to Python's defaultdict, with one difference: defaultdict actually stores the default in the dictionary, while this proxy just hands one back without adding anything to the object. Note the limit: this handler traps reads only. Writes are not intercepted, so an assignment goes straight through to the target and mutates it. Keeping the target untouched takes a set trap, which the next section adds.
function withDefaults(target, defaults) {
return new Proxy(target, {
get(obj, key) {
if (Reflect.has(obj, key)) {
return Reflect.get(obj, key);
}
return key in defaults ? defaults[key] : undefined;
}
});
}
const settings = withDefaults(
{ theme: "dark" },
{ theme: "light", language: "en", fontSize: 14 }
);
console.log(settings.theme); // dark (own property takes priority)
console.log(settings.language); // en (from defaults)
console.log(settings.fontSize); // 14 (from defaults)
console.log(settings.unknown); // undefined
// Only reads are trapped, so this write is NOT intercepted:
// it falls through and adds language to the original object
settings.language = "fr";
console.log(settings.language); // fr (now an own property of the target)
The has and deleteProperty Traps
Proxies can intercept more than just reads and writes. The has trap controls the in operator, and deleteProperty intercepts delete.
const readOnly = (target) =>
new Proxy(target, {
set(obj, key) {
console.warn(`Blocked: cannot set "${key}" on a read-only object`);
return true; // returning false would throw in strict mode
},
deleteProperty(obj, key) {
console.warn(`Blocked: cannot delete "${key}" from a read-only object`);
return true;
}
});
const config = readOnly({ host: "localhost", port: 3000 });
config.port = 9000; // Blocked: cannot set "port" on a read-only object
delete config.host; // Blocked: cannot delete "host" from a read-only object
console.log(config.host, config.port); // localhost 3000
Predict
This set trap rejects any non-number by returning false. The code assigns a number, then a string, then reads both back. This runs in the playground — a plain, non-strict script. What does it log?
const scores = new Proxy(
{},
{
set(target, key, value) {
if (typeof value !== "number") {
return false;
}
target[key] = value;
return true;
},
},
);
scores.math = 90;
scores.science = "A+";
console.log(scores.math);
console.log(scores.science);
console.log("done");Recall
Without scrolling up: the whole point of passing receiver through Reflect.get is to keep one earlier concept correct when a getter runs on a proxy that sits in a prototype chain. Which concept — and why does dropping receiver break it?
Try It Yourself
This is a build task: a small program that reports its own pass/fail. Three proxy factories are stubbed out with only their signatures — you write the handler. Run it as-is and it fails at the first check; implement each function until it prints All checks passed.
Each factory is one trap pattern from this lesson. withDefaults is the get-trap fallback from Practical Pattern: Default Property Values — return the target's value when the key is present on it, otherwise the fallback. readOnly is the write-blocking set trap from The has and deleteProperty Traps: it must accept the assignment yet leave the target unchanged. Remember the playground runs a non-strict script, so a set trap that returns true without writing swallows the assignment silently — no throw, the original value survives. logged is the read-tracking get trap from What Is a Proxy? and The Reflect API — record each accessed key, then forward the read with Reflect.get. The spec for each is in the prompt; the checks below the divider are fixed.
Build
Finish the build. Three proxy factories are stubbed with only their signatures; the checks below them fail until each returns a working proxy. Spec: withDefaults(obj, fallback) returns a proxy whose get trap returns the target's value when the key is present on the target and returns fallback otherwise (use Reflect.has to test — it is the in operator — and Reflect.get to read). readOnly(obj) returns a proxy whose set trap accepts the assignment but never changes the target, so every read still sees the original value — this is a NON-STRICT script, so a set trap that returns true without writing swallows the write silently, no throw. logged(obj, log) returns a proxy whose get trap pushes each accessed key onto the log array in access order, then forwards the read with Reflect.get. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.
const assert = require('assert');
// TODO 1: withDefaults(obj, fallback) -> proxy; get returns own value, else fallback
function withDefaults(obj, fallback) {
// your code here
}
// TODO 2: readOnly(obj) -> proxy; set is accepted but silently ignored (non-strict)
function readOnly(obj) {
// your code here
}
// TODO 3: logged(obj, log) -> proxy; get pushes the key onto log, then forwards the read
function logged(obj, log) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(
typeof withDefaults({ theme: "dark" }, "unset"),
"object",
"withDefaults (TODO 1) should return a proxy object you can read properties from",
);
const settings = withDefaults({ theme: "dark" }, "unset");
assert.strictEqual(
settings.theme,
"dark",
"withDefaults should return the own property when it exists",
);
assert.strictEqual(
settings.language,
"unset",
"withDefaults should return the fallback for a missing key",
);
const frozen = readOnly({ port: 3000 });
frozen.port = 9999;
assert.strictEqual(
frozen.port,
3000,
"readOnly should silently ignore the write and keep the original value",
);
const reads = [];
const tracked = logged({ a: 1, b: 2 }, reads);
tracked.a;
tracked.b;
assert.deepStrictEqual(
reads,
["a", "b"],
"logged should push each accessed key onto the log in order",
);
console.log("All checks passed.");
console.log("Theme:", settings.theme);
console.log("Language:", settings.language);
console.log("Port after blocked write:", frozen.port);
console.log("Reads:", reads);Expected output: All checks passed.
Theme: dark
Language: unset
Port after blocked write: 3000
Reads: [ 'a', 'b' ]
Once it passes, try two variations and predict each before running:
- An own key set to
undefined. BuildwithDefaults({ theme: undefined }, "light")and read.theme. Predict the value before running. You getundefined, not"light"—Reflect.hasreports the key as present (an own property can holdundefined), so the guard forwards the real value instead of falling back. The fallback fires on a missing key, not on a present-but-undefinedone. - Read back a blocked new key. On a
readOnly({ host: "localhost" })proxy, runcfg.newKey = "added"and then logcfg.newKey. Predict the value before running. You getundefined: thesettrap returnedtruewithout writing, so no key was ever created — the silent-ignore blocks brand-new keys just as it blocks overwrites, and non-strict code raises no error either way.
Arrange the code
Reassemble a program that wraps an object in a proxy whose get trap upper-cases every string it returns, reads two properties through it, and logs the combined result. The lines are shuffled — each line uses a const from the line before it, so only one order runs top-to-bottom and logs ADA LOVELACE.
console.log(shout);const shout = proxy.first + " " + proxy.last;const target = { first: "Ada", last: "Lovelace" };const proxy = new Proxy(target, { get: (o, k) => Reflect.get(o, k).toUpperCase() });
Key Takeaways
- A
Proxywraps any object and intercepts operations through traps defined in a handler. - The most commonly used traps are
get,set,has, anddeleteProperty. Reflectmethods mirror every proxy trap and execute the default engine behaviour — use them to forward operations cleanly rather than reimplementing them.- Always pass the
receiverargument throughReflect.getandReflect.setto preserve correctthisbinding in prototype chains. - Set traps must return
trueto signal success; returningfalsein strict mode throws aTypeError. - Proxies are transparent to most code — callers cannot tell they are interacting with a proxy rather than the original object.
- Common real-world uses include input validation, change tracking, default values, access control, and building reactive data systems.
Pro Tip: Proxies are not free. Every intercepted operation adds overhead compared to a direct property access. For hot paths — tight loops or high-frequency event handlers — measure before adding a proxy layer. Use them at architectural boundaries (state containers, configuration objects, API response wrappers) where the ergonomic gains outweigh the cost.
Next Steps
You have seen how to intercept object operations — next, you will learn how to move heavy computation off the main thread entirely using Web Workers, keeping your UI responsive even during intensive processing.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.