How to Use Proxy and Reflect in JavaScript
JavaScript gives you the power to intercept and redefine fundamental operations on objects. Want to validate every property assignment? Log every property access? Create objects that react automatically when their data changes? The Proxy and Reflect APIs make all of this possible.
Introduced in ES2015, Proxy lets you wrap an object and intercept operations like reading, writing, deleting properties, function calls, and more. Reflect serves as its perfect companion, providing default implementations for every operation a Proxy can intercept.
This guide walks you through everything you need to build powerful, production-ready proxies, from basic traps to real-world patterns used by frameworks like Vue.js.
What Is a Proxy? (Intercepting Operations)
A Proxy is a wrapper around an object (called the target) that intercepts operations performed on it. You define the behavior for each intercepted operation inside a handler object. Each intercepted operation is called a trap.
Think of a Proxy as a security guard standing in front of a building (the target object). Every visitor (operation) must go through the guard, who decides what happens: allow it, modify it, block it, or log it.
const target = {
name: "Alice",
age: 30
};
const handler = {
get(target, property, receiver) {
console.log(`Reading property "${property}"`);
return target[property];
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.name);
// Reading property "name"
// "Alice"
console.log(proxy.age);
// Reading property "age"
// 30
The three key players are:
| Term | Description |
|---|---|
| target | The original object being wrapped |
| handler | An object containing trap methods that define custom behavior |
| trap | A method in the handler that intercepts a specific operation |
If the handler is empty (no traps defined), the proxy acts as a transparent passthrough to the target:
const target = { message: "hello" };
const proxy = new Proxy(target, {});
console.log(proxy.message); // "hello"
proxy.message = "world";
console.log(target.message); // "world"
Operations performed on the proxy affect the underlying target object. The proxy and target share the same data unless a trap explicitly changes this behavior.
Proxy Traps: get, set, has, deleteProperty, apply, construct, and More
JavaScript defines 13 internal operations on objects, and Proxy provides a trap for each one. Here is the complete list:
| Trap | Intercepts | Internal Method |
|---|---|---|
get | Property read | [[Get]] |
set | Property write | [[Set]] |
has | in operator | [[HasProperty]] |
deleteProperty | delete operator | [[Delete]] |
ownKeys | Object.keys, for...in, etc. | [[OwnPropertyKeys]] |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor | [[GetOwnProperty]] |
defineProperty | Object.defineProperty | [[DefineOwnProperty]] |
getPrototypeOf | Object.getPrototypeOf | [[GetPrototypeOf]] |
setPrototypeOf | Object.setPrototypeOf | [[SetPrototypeOf]] |
isExtensible | Object.isExtensible | [[IsExtensible]] |
preventExtensions | Object.preventExtensions | [[PreventExtensions]] |
apply | Function call | [[Call]] |
construct | new operator | [[Construct]] |
Let's explore the most commonly used traps with practical examples.
The get Trap
The get trap intercepts property reads. It receives three arguments: the target, the property name (as a string or symbol), and the receiver (usually the proxy itself).
A classic use case is returning a default value for missing properties instead of undefined:
const defaults = new Proxy({}, {
get(target, property) {
return property in target ? target[property] : `Property "${property}" not found`;
}
});
defaults.name = "Alice";
console.log(defaults.name); // "Alice"
console.log(defaults.address); // 'Property "address" not found'
The set Trap
The set trap intercepts property writes. It must return true if the assignment succeeded, or false (which throws a TypeError in strict mode) if it should be rejected.
const user = new Proxy({}, {
set(target, property, value, receiver) {
if (property === "age" && typeof value !== "number") {
throw new TypeError("Age must be a number");
}
if (property === "age" && (value < 0 || value > 150)) {
throw new RangeError("Age must be between 0 and 150");
}
target[property] = value;
return true;
}
});
user.name = "Alice"; // Works fine
user.age = 30; // Works fine
user.age = -5; // RangeError: Age must be between 0 and 150
user.age = "thirty"; // TypeError: Age must be a number
The has Trap
The has trap intercepts the in operator:
const range = {
start: 1,
end: 10
};
const rangeProxy = new Proxy(range, {
has(target, property) {
if (typeof property === "string" && !isNaN(property)) {
const num = Number(property);
return num >= target.start && num <= target.end;
}
return property in target;
}
});
console.log(5 in rangeProxy); // true
console.log(15 in rangeProxy); // false
console.log("start" in rangeProxy); // true
The deleteProperty Trap
The deleteProperty trap intercepts the delete operator. You can use it to protect certain properties from deletion:
const protected = new Proxy({ id: 1, name: "Alice", role: "admin" }, {
deleteProperty(target, property) {
if (property === "id") {
throw new Error("Cannot delete the id property");
}
delete target[property];
return true;
}
});
delete protected.name; // Works
console.log(protected.name); // undefined
delete protected.id; // Error: Cannot delete the id property
The apply Trap
The apply trap intercepts function calls. The target must be a function:
function sum(a, b) {
return a + b;
}
const loggedSum = new Proxy(sum, {
apply(target, thisArg, argumentsList) {
console.log(`Called with args: ${argumentsList}`);
const result = target.apply(thisArg, argumentsList);
console.log(`Returned: ${result}`);
return result;
}
});
loggedSum(3, 4);
// Called with args: 3,4
// Returned: 7
The construct Trap
The construct trap intercepts the new operator:
class User {
constructor(name) {
this.name = name;
}
}
const TrackedUser = new Proxy(User, {
construct(target, args, newTarget) {
console.log(`Creating new User: ${args[0]}`);
return new target(...args);
}
});
const alice = new TrackedUser("Alice");
// Creating new User: Alice
console.log(alice.name); // "Alice"
The ownKeys Trap
The ownKeys trap intercepts Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertySymbols(), and for...in. A common use is hiding private (underscore-prefixed) properties:
const obj = {
name: "Alice",
_secret: "hidden",
age: 30,
_internal: 42
};
const safeObj = new Proxy(obj, {
ownKeys(target) {
return Object.keys(target).filter(key => !key.startsWith("_"));
}
});
console.log(Object.keys(safeObj)); // ["name", "age"]
When filtering keys with ownKeys, you may also need to implement getOwnPropertyDescriptor to return descriptors for the keys you expose, because some methods (like Object.keys) check that returned keys actually exist and are enumerable on the target.
Reflect API: Default Operation Forwarding
The Reflect object provides static methods that mirror every Proxy trap. Each Reflect method performs the default behavior of the corresponding internal operation.
Why does this matter? Because inside a trap, you often want to do the default thing plus something extra. Reflect gives you a clean, reliable way to forward the operation:
const user = { name: "Alice", age: 30 };
const proxy = new Proxy(user, {
get(target, property, receiver) {
console.log(`Accessing: ${property}`);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log(`Setting: ${property} = ${value}`);
return Reflect.set(target, property, value, receiver);
}
});
proxy.name; // Accessing: name
proxy.age = 31; // Setting: age = 31
Why Use Reflect Instead of Direct Operations?
You might wonder why we write Reflect.get(target, property, receiver) instead of just target[property]. There are important reasons:
1. Correct receiver forwarding for inherited getters:
const parent = {
_name: "Parent",
get name() {
return this._name;
}
};
const handler = {
get(target, property, receiver) {
// WRONG: target[property] uses `target` as `this`
// return target[property];
// CORRECT: Reflect.get passes `receiver` as `this`
return Reflect.get(target, property, receiver);
}
};
const proxy = new Proxy(parent, handler);
const child = Object.create(proxy);
child._name = "Child";
// With Reflect.get: "Child" (correct)
// With target[property]: "Parent" (wrong, ignores inheritance)
console.log(child.name);
2. Consistent return values: Reflect.set() returns a boolean indicating success, which matches what the set trap expects.
3. One-to-one correspondence: Every trap has a matching Reflect method with the exact same signature.
Here is the full correspondence table:
| Proxy Trap | Reflect Method |
|---|---|
get(target, prop, receiver) | Reflect.get(target, prop, receiver) |
set(target, prop, value, receiver) | Reflect.set(target, prop, value, receiver) |
has(target, prop) | Reflect.has(target, prop) |
deleteProperty(target, prop) | Reflect.deleteProperty(target, prop) |
ownKeys(target) | Reflect.ownKeys(target) |
apply(target, thisArg, args) | Reflect.apply(target, thisArg, args) |
construct(target, args, newTarget) | Reflect.construct(target, args, newTarget) |
Reflect is not a constructor. You cannot use new Reflect(). All its methods are static, similar to the Math object.
Validation Proxies
One of the most practical uses of Proxy is building self-validating objects. Instead of scattering validation logic throughout your code, you centralize it in the proxy handler.
Basic Type Validation
function createTypedObject(schema) {
return new Proxy({}, {
set(target, property, value) {
if (property in schema) {
const expectedType = schema[property];
const actualType = typeof value;
if (actualType !== expectedType) {
throw new TypeError(
`Property "${property}" must be of type "${expectedType}", got "${actualType}"`
);
}
}
target[property] = value;
return true;
}
});
}
const user = createTypedObject({
name: "string",
age: "number",
active: "boolean"
});
user.name = "Alice"; // OK
user.age = 30; // OK
user.active = true; // OK
user.age = "thirty";
// TypeError: Property "age" must be of type "number", got "string"
Advanced Validation with Custom Rules
function createValidatedObject(validators) {
return new Proxy({}, {
set(target, property, value) {
const validator = validators[property];
if (validator) {
const result = validator(value);
if (result !== true) {
throw new Error(`Validation failed for "${property}": ${result}`);
}
}
target[property] = value;
return true;
}
});
}
const product = createValidatedObject({
name: (v) => typeof v === "string" && v.length > 0 ? true : "must be a non-empty string",
price: (v) => typeof v === "number" && v > 0 ? true : "must be a positive number",
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? true : "must be a valid email"
});
product.name = "Widget"; // OK
product.price = 9.99; // OK
product.email = "a@b.com"; // OK
product.price = -5;
// Error: Validation failed for "price": must be a positive number
product.email = "not-an-email";
// Error: Validation failed for "email": must be a valid email
Read-Only Proxy
You can create a completely immutable view of an object:
function readOnly(target) {
return new Proxy(target, {
set() {
throw new Error("This object is read-only");
},
deleteProperty() {
throw new Error("This object is read-only");
},
defineProperty() {
throw new Error("This object is read-only");
}
});
}
const config = readOnly({ apiUrl: "https://api.example.com", timeout: 5000 });
console.log(config.apiUrl); // "https://api.example.com"
config.apiUrl = "hacked"; // Error: This object is read-only
Observable Objects with Proxies
You can build an observer pattern where functions are called automatically whenever an object changes. This is the foundation of reactivity in modern frameworks.
function makeObservable(target) {
const listeners = new Map();
target.on = function(property, callback) {
if (!listeners.has(property)) {
listeners.set(property, new Set());
}
listeners.get(property).add(callback);
};
target.off = function(property, callback) {
if (listeners.has(property)) {
listeners.get(property).delete(callback);
}
};
return new Proxy(target, {
set(target, property, value, receiver) {
const oldValue = target[property];
const result = Reflect.set(target, property, value, receiver);
if (result && oldValue !== value && listeners.has(property)) {
for (const callback of listeners.get(property)) {
callback(value, oldValue, property);
}
}
return result;
}
});
}
const user = makeObservable({ name: "Alice", age: 30 });
user.on("name", (newVal, oldVal) => {
console.log(`Name changed: "${oldVal}" → "${newVal}"`);
});
user.on("age", (newVal, oldVal) => {
console.log(`Age changed: ${oldVal} → ${newVal}`);
});
user.name = "Bob"; // Name changed: "Alice" → "Bob"
user.age = 31; // Age changed: 30 → 31
user.name = "Bob"; // (no output, value didn't change)
A more advanced version can watch for any property change:
function reactive(target, onChange) {
return new Proxy(target, {
set(target, property, value, receiver) {
const oldValue = target[property];
const result = Reflect.set(target, property, value, receiver);
if (result && oldValue !== value) {
onChange(property, value, oldValue);
}
return result;
},
deleteProperty(target, property) {
const oldValue = target[property];
const result = Reflect.deleteProperty(target, property);
if (result) {
onChange(property, undefined, oldValue);
}
return result;
}
});
}
const state = reactive({ count: 0 }, (prop, newVal, oldVal) => {
console.log(`[state.${prop}] ${oldVal} → ${newVal}`);
});
state.count = 1; // [state.count] 0 → 1
state.count = 2; // [state.count] 1 → 2
delete state.count; // [state.count] 2 → undefined
Revocable Proxies
Sometimes you need a proxy that can be permanently disabled. Proxy.revocable() creates a proxy with a revoke function. Once revoked, any operation on the proxy throws a TypeError.
const { proxy, revoke } = Proxy.revocable(
{ secret: "classified" },
{
get(target, property) {
return Reflect.get(target, property);
}
}
);
console.log(proxy.secret); // "classified"
revoke();
console.log(proxy.secret);
// TypeError: Cannot perform 'get' on a proxy that has been revoked
This is useful for temporary access control. For example, granting an API token that expires:
function createTemporaryAccess(data, durationMs) {
const { proxy, revoke } = Proxy.revocable(data, {});
setTimeout(() => {
revoke();
console.log("Access revoked");
}, durationMs);
return proxy;
}
const tempData = createTemporaryAccess({ key: "abc123" }, 5000);
console.log(tempData.key); // "abc123"
// After 5 seconds:
// "Access revoked"
// tempData.key → TypeError
After revocation, there is no way to restore the proxy. The internal reference to the target is permanently severed. This is by design and makes revocable proxies a strong security mechanism.
Proxy Limitations
While proxies are powerful, they have important limitations you must understand.
Private Properties Are Inaccessible
Proxies cannot intercept access to private class fields (#field). Private fields are checked against the internal slot of the exact object, and since the proxy is a different object, access fails:
class Secret {
#value;
constructor(v) {
this.#value = v;
}
getValue() {
return this.#value;
}
}
const secret = new Secret(42);
const proxy = new Proxy(secret, {});
console.log(proxy.getValue());
// TypeError: Cannot read private member #value from an object
// whose class did not declare it
The workaround is to bind methods to the target inside the get trap:
const proxy = new Proxy(secret, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value === "function") {
return value.bind(target); // bind methods to the original object
}
return value;
}
});
console.log(proxy.getValue()); // 42
This workaround means the method runs with this pointing to the target, not the proxy. Any this-based trap logic inside that method call will be bypassed.
Identity: proxy !== target
A proxy and its target are different objects. This means strict equality checks will fail:
const target = {};
const proxy = new Proxy(target, {});
console.log(proxy === target); // false
This can cause subtle bugs when objects are stored in Maps or Sets, or when identity checks are important:
const map = new Map();
const target = { id: 1 };
const proxy = new Proxy(target, {});
map.set(target, "original");
console.log(map.get(proxy)); // undefined (proxy is a different key)
console.log(map.get(target)); // "original"
Built-In Objects with Internal Slots
Some built-in objects like Map, Set, Date, and Promise use internal slots that proxies cannot intercept. This causes operations to fail:
const map = new Map();
const proxy = new Proxy(map, {});
proxy.set("key", "value");
// TypeError: Method Map.prototype.set called on incompatible receiver
The fix is the same bind-to-target pattern:
const proxy = new Proxy(map, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value === "function") {
return value.bind(target);
}
return value;
}
});
proxy.set("key", "value"); // Works
console.log(proxy.get("key")); // "value"
Performance Overhead
Every operation on a proxy goes through an additional layer of indirection. For hot paths (tight loops, high-frequency operations), this overhead can be measurable:
const target = { x: 0 };
const proxy = new Proxy(target, {
get(t, p) { return Reflect.get(t, p); },
set(t, p, v) { return Reflect.set(t, p, v); }
});
console.time("direct");
for (let i = 0; i < 1_000_000; i++) target.x = i;
console.timeEnd("direct"); // ~3ms
console.time("proxy");
for (let i = 0; i < 1_000_000; i++) proxy.x = i;
console.timeEnd("proxy"); // ~30ms (roughly 10x slower)
In most applications, proxy overhead is negligible. Only optimize if profiling shows proxies are a bottleneck in performance-critical code paths.
Real-World Uses
Reactive Systems (Vue.js 3)
Vue.js 3 replaced Object.defineProperty (used in Vue 2) with Proxy for its reactivity system. Here is a simplified version of how it works:
const activeEffect = { current: null };
const targetMap = new WeakMap();
function track(target, property) {
if (!activeEffect.current) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(property);
if (!deps) {
deps = new Set();
depsMap.set(property, deps);
}
deps.add(activeEffect.current);
}
function trigger(target, property) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(property);
if (deps) {
deps.forEach(effect => effect());
}
}
function reactive(obj) {
return new Proxy(obj, {
get(target, property, receiver) {
track(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
const result = Reflect.set(target, property, value, receiver);
trigger(target, property);
return result;
}
});
}
function watchEffect(fn) {
activeEffect.current = fn;
fn();
activeEffect.current = null;
}
// Usage
const state = reactive({ count: 0 });
watchEffect(() => {
console.log(`Count is: ${state.count}`);
});
// Immediately logs: "Count is: 0"
state.count = 1; // "Count is: 1"
state.count = 2; // "Count is: 2"
This tracks which properties each effect depends on, and re-runs only the relevant effects when those properties change.
Logging and Debugging
Create a proxy that logs every interaction with an object for debugging purposes:
function createLogger(target, label = "Object") {
return new Proxy(target, {
get(target, property, receiver) {
console.log(`[${label}] GET .${String(property)}`);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log(`[${label}] SET .${String(property)} = ${JSON.stringify(value)}`);
return Reflect.set(target, property, value, receiver);
},
deleteProperty(target, property) {
console.log(`[${label}] DELETE .${String(property)}`);
return Reflect.deleteProperty(target, property);
},
has(target, property) {
console.log(`[${label}] HAS .${String(property)}`);
return Reflect.has(target, property);
}
});
}
const config = createLogger({ debug: true, version: "1.0" }, "Config");
config.debug; // [Config] GET .debug
config.version = "2.0"; // [Config] SET .version = "2.0"
"debug" in config; // [Config] HAS .debug
delete config.debug; // [Config] DELETE .debug
Access Control
Restrict access based on user roles:
function withAccessControl(target, allowedRoles, currentRole) {
return new Proxy(target, {
get(target, property, receiver) {
if (!allowedRoles.includes(currentRole)) {
throw new Error(`Access denied: role "${currentRole}" cannot read properties`);
}
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
if (currentRole !== "admin") {
throw new Error(`Access denied: only "admin" can modify properties`);
}
return Reflect.set(target, property, value, receiver);
}
});
}
const sensitiveData = { salary: 100000, ssn: "123-45-6789" };
const adminView = withAccessControl(sensitiveData, ["admin", "manager"], "admin");
console.log(adminView.salary); // 100000
adminView.salary = 120000; // Works
const guestView = withAccessControl(sensitiveData, ["admin", "manager"], "guest");
console.log(guestView.salary);
// Error: Access denied: role "guest" cannot read properties
Negative Array Indices
Create arrays that support Python-style negative indexing:
function createNegativeArray(arr) {
return new Proxy(arr, {
get(target, property, receiver) {
const index = Number(property);
if (Number.isInteger(index) && index < 0) {
return target[target.length + index];
}
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
const index = Number(property);
if (Number.isInteger(index) && index < 0) {
target[target.length + index] = value;
return true;
}
return Reflect.set(target, property, value, receiver);
}
});
}
const arr = createNegativeArray(["a", "b", "c", "d", "e"]);
console.log(arr[-1]); // "e"
console.log(arr[-2]); // "d"
arr[-1] = "z";
console.log(arr); // ["a", "b", "c", "d", "z"]
API Wrapper with Auto-URL Building
function createAPIClient(baseURL) {
return new Proxy({}, {
get(target, property) {
return new Proxy(() => {}, {
get(target, subProperty) {
return createAPIClient(`${baseURL}/${property}/${subProperty}`);
},
apply(target, thisArg, args) {
const url = `${baseURL}/${property}`;
console.log(`Fetching: ${url}`, args[0] || "");
return fetch(url, args[0]);
}
});
}
});
}
const api = createAPIClient("https://api.example.com");
// Builds URL: https://api.example.com/users
api.users();
// Builds URL: https://api.example.com/users/posts
api.users.posts();
Proxy Invariants
The JavaScript specification enforces invariants to ensure that proxy traps cannot lie in ways that contradict the target's actual state. If a trap violates an invariant, a TypeError is thrown.
Key invariants include:
getmust return the property value if the target property is non-writable, non-configurablesetmust not change a non-writable, non-configurable propertyhascannot hide an existing, non-configurable propertyownKeysmust include all non-configurable own propertiesgetOwnPropertyDescriptormust not report a non-configurable property as non-existent
const target = {};
Object.defineProperty(target, "locked", {
value: 42,
writable: false,
configurable: false
});
const proxy = new Proxy(target, {
get(target, property) {
if (property === "locked") return 100; // Trying to lie
return Reflect.get(target, property);
}
});
console.log(proxy.locked);
// TypeError: 'get' on proxy: property 'locked' is a read-only and
// non-configurable data property on the proxy target but the proxy
// did not return its actual value
These invariants exist to maintain the integrity of the JavaScript object model. They ensure that Object.freeze, Object.seal, and non-configurable properties remain trustworthy even when proxied.
Summary
| Concept | What It Does |
|---|---|
new Proxy(target, handler) | Creates a proxy wrapping target with traps in handler |
Traps (get, set, etc.) | Methods in the handler that intercept specific operations |
Reflect methods | Provide default behavior for every proxy trap |
Proxy.revocable() | Creates a proxy that can be permanently disabled |
| Invariants | Rules enforced by the engine to prevent traps from lying |
Proxy and Reflect are among the most powerful metaprogramming tools in JavaScript. They enable patterns like validation, reactivity, logging, and access control with clean, centralized code. When used thoughtfully, they make your code more robust and maintainable. When overused, they can introduce complexity and performance overhead.
The golden rule: use proxies when you need to intercept and customize fundamental object operations, and always use Reflect inside your traps for correct default behavior.