Proxy and Reflect: 13 Traps and Complete Metaprogramming
Proxy is the most powerful metaprogramming tool JavaScript has ever had. It lets you install hooks at the lowest level of any object operation — not just property reads and writes, but also the in operator, delete, new, function calls, prototype access, and all 13 fundamental object operations in total. Reflect is its mirror: for every Proxy trap, there is a Reflect method that provides the default behavior for that operation. Vue 3's entire reactivity system is built on these two APIs.
🔹 Level 1 · What You Need to Know
Basic Proxy Structure
const proxy = new Proxy(target, handler);
- target: the original object being proxied (any object, including functions)
- handler: an object containing trap functions
- proxy: the returned proxy object — all operations are intercepted by handler
const target = { name: 'Alice', age: 30 };
const handler = {
get(target, prop, receiver) {
console.log(`reading property: ${prop}`);
return Reflect.get(target, prop, receiver); // forward default behavior
},
set(target, prop, value, receiver) {
console.log(`setting property: ${prop} = ${value}`);
return Reflect.set(target, prop, value, receiver);
}
};
const proxy = new Proxy(target, handler);
proxy.name; // prints: reading property: name → 'Alice'
proxy.name = 'Bob'; // prints: setting property: name = Bob
The 4 Most Commonly Used Traps
get trap (intercepts property reads):
// Simplified Vue 3 reactivity system
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key); // collect dependency
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // notify updates
return result;
}
});
}
has trap (intercepts the in operator):
// Implementing "range" semantics: 5 in range(1, 10)
function range(min, max) {
return new Proxy({}, {
has(target, prop) {
const num = Number(prop);
return !isNaN(num) && num >= min && num <= max;
}
});
}
const r = range(1, 10);
console.log(5 in r); // true
console.log(15 in r); // false
console.log('a' in r); // false
deleteProperty trap (intercepts delete operator):
// Read-only protection
const readOnly = new Proxy({ x: 1 }, {
deleteProperty(target, prop) {
throw new Error(`Cannot delete property: ${prop}`);
},
set(target, prop, value) {
throw new Error(`Cannot modify property: ${prop}`);
}
});
apply trap (intercepts function calls):
// Function call logging
function logged(fn) {
return new Proxy(fn, {
apply(target, thisArg, args) {
console.log(`calling ${fn.name}(${args.join(', ')})`);
const result = Reflect.apply(target, thisArg, args);
console.log(`returned: ${result}`);
return result;
}
});
}
const add = logged((a, b) => a + b);
add(3, 4); // prints: calling (3, 4) → returned: 7
What Reflect Is For
Every Reflect method corresponds to a Proxy trap, letting you execute that operation's default behavior:
// Use Reflect in traps to forward default behavior
const handler = {
get(target, key, receiver) {
// custom logic...
return Reflect.get(target, key, receiver); // forward
}
};
// Key difference between Reflect and Object methods:
// Reflect.defineProperty returns boolean (no throw on failure)
// Object.defineProperty returns the object or throws TypeError
Reflect.defineProperty({}, 'x', { value: 1 }); // true (success)
Reflect.defineProperty(Object.freeze({}), 'x', { value: 1 }); // false (failure, no throw)
5 Common Mistakes
Mistake 1: Operating on target directly in traps instead of using Reflect
// Dangerous: causes incorrect this binding
const handler = {
get(target, key, receiver) {
return target[key]; // wrong! uses target instead of receiver
}
};
// Consider this case:
const proto = new Proxy({}, {
get(target, key, receiver) {
return target[key]; // if a child object accesses an inherited property,
// this will point to proto, not child
}
});
const child = Object.create(proto);
// Correct approach:
const handler2 = {
get(target, key, receiver) {
return Reflect.get(target, key, receiver); // receiver properly forwarded
}
};
Mistake 2: get trap returns a wrong value (violates invariant)
const target = {};
Object.defineProperty(target, 'x', { value: 42, writable: false, configurable: false });
const proxy = new Proxy(target, {
get(target, key) {
return 99; // violates invariant!
}
});
proxy.x; // TypeError: 'get' on proxy: property 'x' is a read-only and non-configurable...
// Spec requires: for writable:false, configurable:false properties,
// the get trap must return the actual value
Mistake 3: set trap forgetting to return true
const proxy = new Proxy({}, {
set(target, key, value) {
target[key] = value;
// forgot to return true!
}
});
proxy.x = 1; // TypeError in strict mode: 'set' on proxy returned false
Mistake 4: Proxying a Proxy object (nested proxies trigger traps twice)
const inner = new Proxy({}, { get(t, k) { console.log('inner get', k); return t[k]; } });
const outer = new Proxy(inner, { get(t, k) { console.log('outer get', k); return t[k]; } });
outer.x;
// Output: outer get x (outer intercepts)
// inner get x (outer uses t[k], triggering inner's trap!)
// Using Reflect.get(t, k, receiver) instead would only trigger once
Mistake 5: Using a revoked Proxy
const { proxy, revoke } = Proxy.revocable({ x: 1 }, {});
proxy.x; // 1
revoke();
proxy.x; // TypeError: Cannot perform 'get' on a proxy that has been revoked
🔸 Level 2 · How It Works Internally
All 13 Traps
| Trap | Internal Method Intercepted | Triggered By |
|---|---|---|
get |
[[Get]] |
property reads, prototype chain lookup |
set |
[[Set]] |
property assignment |
has |
[[HasProperty]] |
in operator |
deleteProperty |
[[Delete]] |
delete operator |
apply |
[[Call]] |
function call fn() |
construct |
[[Construct]] |
new operator |
getPrototypeOf |
[[GetPrototypeOf]] |
Object.getPrototypeOf, instanceof, __proto__ |
setPrototypeOf |
[[SetPrototypeOf]] |
Object.setPrototypeOf, __proto__ = |
isExtensible |
[[IsExtensible]] |
Object.isExtensible |
preventExtensions |
[[PreventExtensions]] |
Object.preventExtensions/seal/freeze |
getOwnPropertyDescriptor |
[[GetOwnProperty]] |
Object.getOwnPropertyDescriptor |
defineProperty |
[[DefineOwnProperty]] |
Object.defineProperty, property creation |
ownKeys |
[[OwnPropertyKeys]] |
Object.keys/values/entries, for...in, Object.getOwnPropertyNames/Symbols |
Vue 3 Reactivity Core in ~40 Lines
A fully functional implementation of Vue 3's reactive core:
// Global storage: WeakMap<object, Map<key, Set<effect>>>
const targetMap = new WeakMap();
let activeEffect = null; // currently running effect
// Track dependency
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect);
}
// Trigger updates
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
if (effects) effects.forEach(effect => effect());
}
// Create a reactive object
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver);
track(target, key); // collect dependency on read
// recursively proxy nested objects
if (typeof value === 'object' && value !== null) {
return reactive(value);
}
return value;
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // notify updates on write
return result;
}
});
}
// Register an effect (side-effect function)
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn;
fn(); // execute → triggers dependency collection
activeEffect = null;
};
effectFn(); // run once immediately
return effectFn;
}
// Usage:
const state = reactive({ count: 0, name: 'Vue' });
effect(() => {
console.log(`count is ${state.count}`);
// executing reads count → track(state, 'count') → adds this effect to deps
});
state.count++; // trigger(state, 'count') → re-runs effect → prints "count is 1"
state.name = 'Vue 3'; // trigger(state, 'name') → no subscribers, no output
The Invariant System
The spec constrains every Proxy trap's return value. Violations throw TypeError — this is a security mechanism:
Key invariants per trap:
get trap:
If a property on target is writable:false AND configurable:false
→ get must return the same value as target's actual value
set trap:
If a property on target is writable:false AND configurable:false
→ set cannot succeed (must return false, otherwise TypeError)
has trap:
If a property on target is configurable:false
→ has must return true (cannot pretend property doesn't exist)
If target is non-extensible and property doesn't exist
→ has must return false
deleteProperty trap:
If a property on target is configurable:false
→ deleteProperty cannot return true
ownKeys trap:
If target is non-extensible
→ ownKeys must include all of target's own property keys,
no extra keys allowed
All configurable:false property keys must appear in the result
getPrototypeOf trap:
If target is non-extensible
→ must return the same value as target's actual prototype
isExtensible trap:
Return value must match target's actual extensible state
(strictest invariant — cannot lie about this)
🔺 Level 3 · How the Spec Defines It
Spec §10.5: Proxy Object Internal Methods
Spec §10.5.8 defines Proxy [[Get]], which typifies the structure of all traps:
Proxy [[Get]] (P, Receiver) (§10.5.8):
1. handler ← O.[[ProxyHandler]]
If handler is null → TypeError (proxy has been revoked)
2. target ← O.[[ProxyTarget]]
3. trap ← GetMethod(handler, "get")
If trap is undefined → return target.[[Get]](P, Receiver)
(no trap → use default behavior)
4. trapResult ← Call(trap, handler, [target, P, Receiver])
(invoke the trap function)
5. Invariant check:
targetDesc ← target.[[GetOwnProperty]](P)
If targetDesc is not undefined:
If IsDataDescriptor(targetDesc):
If targetDesc.[[Configurable]] is false
AND targetDesc.[[Writable]] is false:
If SameValue(trapResult, targetDesc.[[Value]]) is false
→ TypeError (cannot return different value for non-configurable, non-writable property)
If IsAccessorDescriptor(targetDesc):
If targetDesc.[[Configurable]] is false
AND targetDesc.[[Get]] is undefined:
If trapResult is not undefined
→ TypeError (accessor with undefined [[Get]] must return undefined)
6. Return trapResult
The Precise Invariants of the ownKeys Trap
The ownKeys trap has the most complex invariants of all (§10.5.11):
Spec constraints (Proxy [[OwnPropertyKeys]]):
Let trapResultKeys = the list returned by the trap
Let targetKeys = target.[[OwnPropertyKeys]]()
Constraint 1: trapResultKeys has no duplicate keys
Constraint 2: every element of trapResultKeys is a String or Symbol
If target is non-extensible:
Constraint 3: trapResultKeys must contain all elements of targetKeys
Constraint 4: every element of targetKeys must appear in trapResultKeys exactly once
If target is extensible:
Constraint 5: all configurable:false properties in targetKeys must appear in trapResultKeys
// Demonstrating invariant violation:
const target = Object.freeze({ x: 1, y: 2 }); // non-extensible
const proxy = new Proxy(target, {
ownKeys() {
return ['x']; // missing 'y' — violates constraint 3
}
});
Object.keys(proxy); // TypeError: 'ownKeys' on proxy: trap result did not include 'y'
// Correct use of ownKeys (filtering enumerable properties):
const proxy2 = new Proxy({ a: 1, _b: 2, c: 3 }, {
ownKeys(target) {
// only expose keys not starting with _
return Reflect.ownKeys(target).filter(k => !String(k).startsWith('_'));
},
getOwnPropertyDescriptor(target, key) {
// must also override getOwnPropertyDescriptor, otherwise Object.keys
// will filter out these keys (they need to be enumerable own properties)
if (!String(key).startsWith('_')) {
return { ...Object.getOwnPropertyDescriptor(target, key), enumerable: true };
}
return undefined;
}
});
Object.keys(proxy2); // ['a', 'c']
defineProperty Trap Invariants
Spec constraints (Proxy [[DefineOwnProperty]]):
Constraint 1: if target is non-extensible, trap cannot return true to add non-existent property
Constraint 2: if target has a configurable:false property,
trap cannot return true to make disallowed changes:
- cannot change configurable to true
- cannot change enumerable
- if writable:false, cannot change value (unless SameValue)
- cannot change writable from false to true
Constraint 3: if target has configurable:false and writable:false property,
cannot modify it in any way
💎 Level 4 · Edge Cases and Traps
Trap 1: Proxy Cannot Wrap Primitive Values
new Proxy(42, {}); // TypeError: Cannot create proxy with a non-object as target
new Proxy(null, {}); // TypeError
new Proxy('str', {}); // TypeError
new Proxy(true, {}); // TypeError
// Can only proxy objects (including functions):
new Proxy({}, {}); // OK
new Proxy([], {}); // OK
new Proxy(function(){}, {}); // OK
new Proxy(class {}, {}); // OK
// Why? Proxy intercepts internal methods ([[Get]], [[Set]], etc.)
// Primitives have no internal methods — there's nothing to intercept
Trap 2: Precise Trigger Conditions for get Trap Invariant Violations
This is the trap that most easily produces TypeError. Full derivation:
const target = {};
Object.defineProperty(target, 'frozen', {
value: 42,
writable: false,
configurable: false
});
Object.defineProperty(target, 'accessorFrozen', {
get: undefined, // getter is undefined
configurable: false
});
const proxy = new Proxy(target, {
get(target, key) {
if (key === 'frozen') return 99; // violates invariant!
if (key === 'accessorFrozen') return 1; // violates invariant!
// (getter is undefined → must return undefined)
return target[key];
}
});
proxy.frozen; // TypeError: cannot return different value for non-writable, non-configurable property
proxy.accessorFrozen; // TypeError: accessor's [[Get]] is undefined — must return undefined
// Correct: use Reflect.get which handles invariants automatically
const proxy2 = new Proxy(target, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
}
});
proxy2.frozen; // 42 — correct
Trap 3: Proxy of a Proxy Triggers Traps Twice
let getCount = 0;
const inner = new Proxy({ x: 1 }, {
get(target, key, receiver) {
getCount++;
console.log(`inner get #${getCount}: ${key}`);
return Reflect.get(target, key, receiver);
}
});
const outer = new Proxy(inner, {
get(target, key, receiver) {
getCount++;
console.log(`outer get #${getCount}: ${key}`);
return target[key]; // wrong! target is inner, target[key] fires inner's get trap
}
});
outer.x;
// Output:
// outer get #1: x
// inner get #2: x
// Using Reflect.get(target, key, receiver) instead of target[key]
// still fires twice! Because receiver is outer, and Reflect.get forwards to inner
// To avoid double-firing in outer, directly operate on innerTarget:
const innerTarget = { x: 1 };
// But usually, nested proxies are intentionally designed to fire both traps
Legitimate use case for nested proxies:
// outer: logging
// inner: validation
// Both should fire — this is intentional
const validated = new Proxy({ x: 1 }, {
set(target, key, value) {
if (typeof value !== 'number') throw new TypeError('Only numbers allowed');
return Reflect.set(target, key, value);
}
});
const logged = new Proxy(validated, {
set(target, key, value, receiver) {
console.log(`Setting ${key} = ${value}`);
return Reflect.set(target, key, value, receiver); // propagates to validated's set
}
});
logged.x = 2; // prints log + passes validation
logged.x = 'a'; // prints log + validation throws TypeError
Trap 4: Proxy.revocable in Detail
const { proxy, revoke } = Proxy.revocable({ x: 1, y: 2 }, {
get(target, key) {
return Reflect.get(target, key);
}
});
// Use the proxy
console.log(proxy.x); // 1
// Revoke it
revoke();
// Any operation now throws:
proxy.x; // TypeError: Cannot perform 'get' on a proxy that has been revoked
proxy.x = 1; // TypeError
delete proxy.x; // TypeError
'x' in proxy; // TypeError
Object.keys(proxy); // TypeError
// revoke is idempotent:
revoke(); // no error
// Use case: temporary access permissions
function withTemporaryAccess(sensitiveObj, operation) {
const { proxy, revoke } = Proxy.revocable(sensitiveObj, {});
try {
return operation(proxy);
} finally {
revoke(); // access revoked no matter what
}
}
const secret = { token: 'secret-value' };
withTemporaryAccess(secret, (p) => {
console.log(p.token); // 'secret-value'
// after operation completes, proxy is automatically revoked
});
Trap 5: Exact Differences Between Reflect and Object Methods
| Operation | Reflect version | Object version | Key difference |
|---|---|---|---|
| Define property | Reflect.defineProperty(obj, key, desc) → boolean |
Object.defineProperty(obj, key, desc) → obj or TypeError |
Reflect returns false on failure; Object throws |
| Delete property | Reflect.deleteProperty(obj, key) → boolean |
delete obj.key → boolean (non-strict) or TypeError (strict + configurable:false) |
Reflect is more consistent |
| Check property | Reflect.has(obj, key) → boolean |
key in obj → boolean |
Functionally identical |
| Get prototype | Reflect.getPrototypeOf(obj) |
Object.getPrototypeOf(obj) |
For non-objects: Reflect throws TypeError; modern Object also throws |
| Prevent extensions | Reflect.preventExtensions(obj) → boolean |
Object.preventExtensions(obj) → obj |
Different return types |
| Get all own keys | Reflect.ownKeys(obj) |
Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)) |
Reflect.ownKeys includes all (String + Symbol), in spec order |
// Important: error handling of Reflect.defineProperty vs Object.defineProperty
const frozen = Object.freeze({ x: 1 });
// Object version: throws
try {
Object.defineProperty(frozen, 'y', { value: 2 });
} catch (e) {
console.log('Object.defineProperty threw:', e.message);
}
// Reflect version: returns false
const success = Reflect.defineProperty(frozen, 'y', { value: 2 });
console.log('Reflect.defineProperty returned:', success); // false
// Inside Proxy traps, Reflect is better:
const proxy = new Proxy({}, {
defineProperty(target, key, desc) {
// With Reflect: failure returns false, Proxy layer handles it correctly
return Reflect.defineProperty(target, key, desc);
// With Object.defineProperty: exception might propagate to the wrong call stack
}
});
Trap 6: "Impossible" Features Enabled by Proxy
Negative array indices (Python-style arr[-1]):
function negativeable(arr) {
return new Proxy(arr, {
get(target, key, receiver) {
const index = Number(key);
if (!isNaN(index) && index < 0) {
key = String(target.length + index);
}
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const index = Number(key);
if (!isNaN(index) && index < 0) {
key = String(target.length + index);
}
return Reflect.set(target, key, value, receiver);
}
});
}
const arr = negativeable([1, 2, 3, 4, 5]);
console.log(arr[-1]); // 5
console.log(arr[-2]); // 4
arr[-1] = 99;
console.log(arr); // [1, 2, 3, 4, 99]
Deep read-only objects (truly recursive immutability):
function deepReadOnly(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver);
if (typeof value === 'object' && value !== null) {
return deepReadOnly(value); // recursively proxy
}
return value;
},
set(target, key, value) {
throw new TypeError(`Cannot set property '${String(key)}' on read-only object`);
},
deleteProperty(target, key) {
throw new TypeError(`Cannot delete property '${String(key)}' on read-only object`);
}
});
}
const config = deepReadOnly({
database: { host: 'localhost', port: 5432 },
app: { port: 3000 }
});
config.database.host; // 'localhost' — readable
config.database.host = 'new'; // TypeError
config.database.port = 9999; // TypeError
delete config.app.port; // TypeError
Auto-populating default values (auto-create nested paths):
function autoDefault(obj = {}) {
return new Proxy(obj, {
get(target, key) {
if (!(key in target)) {
target[key] = autoDefault(); // auto-create child object
}
return target[key];
}
});
}
const config = autoDefault();
config.database.host = 'localhost'; // auto-creates config.database
config.database.port = 5432;
config.app.server.port = 3000; // auto-creates config.app, config.app.server
console.log(config.database.host); // 'localhost'
console.log(config.app.server.port); // 3000
Runtime type safety (runtime type checking):
function typed(obj, schema) {
return new Proxy(obj, {
set(target, key, value) {
if (key in schema) {
const expectedType = schema[key];
if (typeof value !== expectedType) {
throw new TypeError(
`Property '${key}' must be ${expectedType}, got ${typeof value}`
);
}
}
return Reflect.set(target, key, value);
}
});
}
const user = typed({}, {
name: 'string',
age: 'number',
active: 'boolean'
});
user.name = 'Alice'; // OK
user.age = 30; // OK
user.age = '30'; // TypeError: Property 'age' must be number, got string
Chapter Summary
-
Proxy intercepts all 13 object operations, covering every internal method: from property reads and writes (get/set) to prototype access (getPrototypeOf), from key enumeration (ownKeys) to function invocation (apply/construct). Each trap corresponds to exactly one internal method.
-
Reflect is the default implementation for every Proxy trap: calling
Reflect.xxx(target, ...)inside a trap forwards default behavior while correctly passing the receiver (this value). Key distinction: Reflect methods return booleans; corresponding Object methods return objects or throw. -
Invariants are hard spec constraints on trap return values: no matter what a trap implements, the engine validates the return. For non-writable, non-configurable properties,
getmust return the actual value;isExtensiblemust return a value consistent with the target. -
Nested Proxies fire traps multiple times: when an outer proxy accesses an inner proxy using
target[key]instead of operating on the underlying target, the inner trap also fires.Proxy.revocablecreates a revocable proxy; after revocation, any operation throws TypeError. -
Proxy enables true metaprogramming: negative array indices, deep read-only objects, auto-default values, runtime type checking — all of these required large amounts of boilerplate code without Proxy and now take a dozen lines. Vue 3's entire reactivity system is built on get + set traps.