Meta-programming: controlling objects on a new level
JavaScript Proxy lets you place a stand-in in front of an object that intercepts every read, write, delete and function call. Reflect is the symmetric counterpart: it exposes the language's default behaviors as first-class functions. Together they form the foundation for reactive data systems, validation layers, logging proxies and virtual objects in modern JavaScript.
Table of contents
- 1. What is meta-programming with Proxy?
- 2. The 13 traps: a complete overview of interceptable operations
- 3. get and set traps: validation and transformation
- 4. Reflect: default behavior as a first-class function
- 5. apply and construct traps: intercepting functions and classes
- 6. Reactive data: how Vue.js 3 and MobX use Proxy
- 7. Revocable proxies: revoking access under control
- 8. Proxy invariants: what traps must not violate
- 9. Proxy traps compared
- 10. Summary
- 11. FAQ
1. What is meta-programming with Proxy?
Meta-programming means that code can make statements about itself or other code structures and change them. In JavaScript, Proxy is the primary mechanism for meta-programming at the object level: you place a stand-in in front of a target object that can intercept every access to it, reading, writing, deleting, enumerating, calling. Code that uses the object notices nothing, because the Proxy looks like the target object from the outside. Internally, the Proxy handler can run arbitrary logic: validate, transform, log, cache, or deny access entirely.
The difference from normal getter/setter properties (Object.defineProperty) is profound: Proxy doesn't just intercept access to individual properties, it intercepts all fundamental operations on the object, including checking whether a property exists (the in operator), deleting properties (delete), enumerating all properties (for...in), calling as a function, and creating via new. That makes Proxy the most powerful way to extend objects in JavaScript, more powerful than mixins, decorators or prototype manipulation.
2. The 13 traps: a complete overview of interceptable operations
A Proxy handler can implement up to 13 different "traps," each intercepting one fundamental object operation. The most commonly used in everyday code are get (read a property), set (write a property), has (the in operator), deleteProperty (the delete operator) and apply (function call). Less well known but just as powerful are construct (the new operator), ownKeys (Object.keys() and for...in), getOwnPropertyDescriptor, defineProperty, getPrototypeOf, setPrototypeOf, isExtensible and preventExtensions.
Any trap that is not defined on the handler object automatically falls back to the default behavior, as if the operation were performed directly on the target object. That means you only need to implement the traps you actually need. A handler with just a get trap intercepts only read access; every other operation passes through unchanged. This selective interceptability makes Proxy surgically precise compared to global monkey-patching.
// Proxy with get and set traps, validation and logging layer
const createValidatedObject = (target, schema) =>
new Proxy(target, {
// Intercept all property reads
get(target, prop, receiver) {
console.log(`[GET] ${String(prop)}`);
return Reflect.get(target, prop, receiver); // default behavior
},
// Intercept all property writes, validate against schema
set(target, prop, value, receiver) {
if (prop in schema) {
const validator = schema[prop];
if (!validator(value)) {
throw new TypeError(
`Invalid value for "${String(prop)}": ${JSON.stringify(value)}`
);
}
}
console.log(`[SET] ${String(prop)} = ${JSON.stringify(value)}`);
return Reflect.set(target, prop, value, receiver); // default behavior
},
// Intercept delete, prevent deletion of required fields
deleteProperty(target, prop) {
if (prop in schema) {
throw new Error(`Cannot delete required property "${String(prop)}"`);
}
return Reflect.deleteProperty(target, prop);
},
});
const user = createValidatedObject({}, {
name: (v) => typeof v === "string" && v.length > 0,
age: (v) => Number.isInteger(v) && v >= 0 && v <= 150,
email: (v) => typeof v === "string" && v.includes("@"),
});
user.name = "Alice"; // OK
user.age = 30; // OK
user.age = -5; // TypeError: Invalid value for "age"
delete user.name; // Error: Cannot delete required property "name"
3. get and set traps: validation and transformation
The get trap is the most frequently used and most versatile trap. It receives three parameters: target (the target object), prop (the property name as a string or symbol) and receiver (the proxy object itself, relevant for correct this binding in getters). It can return any value: the real value, a transformed value, a default value for missing properties, or the result of a database query for a "virtual object."
The set trap receives four parameters: target, prop, value and receiver. It must return a boolean value: true for success, false for rejection (which triggers a TypeError in strict mode). A common mistake: forgetting to return true when the assignment succeeded. That causes a TypeError after every assignment in strict mode, even if the value was correctly set on the target object. Reflect.set() correctly returns true or false and is therefore the preferred way to implement the default behavior inside a set trap.
4. Reflect: default behavior as a first-class function
Reflect is a built-in object that exposes all fundamental object operations as methods, operations that would otherwise only be accessible via language syntax. Reflect.get(target, prop, receiver) does the same thing as target[prop], but as a callable function. Reflect.set(target, prop, value, receiver) does the same thing as target[prop] = value. That makes Reflect the ideal companion for Proxy: inside any trap you can fall back to Reflect to run the default behavior correctly and in line with the invariants.
Why use Reflect instead of accessing target directly? The decisive difference lies in the receiver parameter. When a Proxy is created for a class instance and the class has getter methods that use this, the getter must be called with the proxy as this, otherwise this points to the target object instead of the proxy. Reflect.get(target, prop, receiver) correctly passes the receiver to getters, while target[prop] ignores the receiver and can therefore behave incorrectly for class-based objects with getters.
// Reflect.get vs direct access, why receiver matters for class getters
class Temperature {
#celsius;
constructor(c) { this.#celsius = c; }
get fahrenheit() { return this.#celsius * 9/5 + 32; } // uses 'this'
}
const temp = new Temperature(100);
const proxy = new Proxy(temp, {
get(target, prop, receiver) {
console.log(`Accessing: ${String(prop)}`);
// CORRECT: passes receiver, 'this' in getter refers to proxy
return Reflect.get(target, prop, receiver);
// WRONG: return target[prop]; 'this' in getter is the target, not proxy
},
});
console.log(proxy.fahrenheit); // 212, correct with Reflect.get
// Reflect as standalone: all fundamental operations as callable functions
const obj = { x: 1, y: 2 };
Reflect.set(obj, "z", 3); // same as obj.z = 3, returns true/false
Reflect.has(obj, "x"); // same as "x" in obj, returns true
Reflect.deleteProperty(obj, "y"); // same as delete obj.y, returns true
Reflect.ownKeys(obj); // ["x", "z"], all own keys incl. Symbols
Reflect.defineProperty(obj, "w", { value: 4, enumerable: true, configurable: true });
5. apply and construct traps: intercepting functions and classes
The apply trap intercepts function calls, both direct calls and calls via .call() and .apply(). It receives target (the function), thisArg (the this context) and argumentsList (the arguments as an array). A Proxy with an apply trap can log function calls, validate arguments, transform return values, or replace the function entirely. That is the foundation for aspect-oriented programming in JavaScript, without a library and without decorators.
The construct trap intercepts new Class() calls. It receives target (the class), argumentsList and newTarget. With the construct trap you can implement factory patterns, control the creation of instances, enforce singletons, or implement dependency injection at the class level. Because both apply and construct require the target object to be a function, a single Proxy cannot equip normal objects with these traps; the attempt results in a TypeError.
6. Reactive data: how Vue.js 3 and MobX use Proxy
Vue.js 3 has completely rebuilt its reactivity system on top of Proxy, moving away from the Object.defineProperty-based approach used in Vue 2. The core concept: a reactive object is a Proxy whose get trap registers the current watcher (component or computed property) as a dependency when a property is read. The set trap notifies all registered dependencies when the value changes. This system is deeper than the Vue 2 system, because it also captures dynamically added properties and array index access without a special Vue.set() syntax.
MobX 6 uses the same approach for its observable() wrapper. Every observable() object is a Proxy that records read access inside reactions and propagates write access as an action. That makes MobX transparent and transparently reactive: ordinary JavaScript code, arrays, objects, classes, can be made reactive without annotations, because the Proxy instruments all access invisibly. This approach is more capable and more ergonomic than explicit signal libraries, but it requires a deep understanding of Proxy semantics to correctly explain edge-case behavior.
// Minimal reactive system using Proxy (simplified Vue 3 / MobX pattern)
let activeEffect = null;
const deps = new WeakMap(); // target -> Map(prop -> Set(effects))
function getDep(target, prop) {
if (!deps.has(target)) deps.set(target, new Map());
const map = deps.get(target);
if (!map.has(prop)) map.set(prop, new Set());
return map.get(prop);
}
function reactive(target) {
return new Proxy(target, {
get(target, prop, receiver) {
// Track: register current effect as dependency
if (activeEffect) getDep(target, prop).add(activeEffect);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
const result = Reflect.set(target, prop, value, receiver);
// Trigger: notify all effects depending on this prop
getDep(target, prop).forEach(effect => effect());
return result;
},
});
}
function effect(fn) {
activeEffect = fn;
fn(); // run once to collect dependencies
activeEffect = null;
}
// Usage: reactive state with automatic dependency tracking
const state = reactive({ count: 0, name: "Alice" });
effect(() => console.log(`Count is now: ${state.count}`)); // logs immediately
state.count++; // triggers effect: "Count is now: 1"
state.count++; // triggers effect: "Count is now: 2"
state.name = "Bob"; // does NOT trigger the count effect
7. Revocable proxies: revoking access under control
Besides the normal new Proxy(target, handler), JavaScript also offers Proxy.revocable(target, handler). This variant returns an object with two properties: proxy (the normal Proxy) and revoke (a function that permanently deactivates the proxy). After calling revoke(), every access to the proxy, reading or writing, throws a TypeError. The target object is left untouched.
Revocable Proxies are especially useful in security contexts: an API key or a database handle can be passed on as a revocable Proxy, and once the permitted access window ends, revoke() is called. Every existing reference to the Proxy becomes worthless, without having to know or notify every holder of a reference. That is the Principle of Least Authority (POLA) applied to JavaScript objects, a fundamental security principle for secure object capabilities.
| Trap | Intercepted operation | Typical use case |
|---|---|---|
| get | obj.prop, obj["prop"] |
Default values, lazy loading, caching, logging |
| set | obj.prop = val |
Validation, reactivity, immutability |
| has | "prop" in obj |
Simulating virtual properties |
| apply | fn(), fn.call() |
Memoization, logging, AOP aspects |
| construct | new Class() |
Factory pattern, singleton, dependency injection |
8. Proxy invariants: what traps must not violate
The specification defines a set of invariants for every trap, rules that the trap must not violate regardless of the logic it implements. If a trap violates an invariant, JavaScript automatically throws a TypeError before the intercepting code can return. This protects the JavaScript type system from inconsistencies that would otherwise lead to undefined behavior.
The most important example: the get trap must not return a different value than the actual one for a non-configurable, non-writable property of the target object. If Object.defineProperty(target, "x", { value: 42, writable: false, configurable: false }) has been called, the get trap must always return 42 for "x". This invariant prevents a Proxy from lying about the contents of an object that cannot be changed. Anyone implementing Proxy traps should know these invariants; violations lead to cryptic TypeErrors that are hard to debug.
// Revocable Proxy, time-limited access to sensitive resources
function createTimedAccess(target, durationMs) {
const { proxy, revoke } = Proxy.revocable(target, {
get(target, prop, receiver) {
console.log(`[Access] ${String(prop)} at ${Date.now()}`);
return Reflect.get(target, prop, receiver);
},
});
// Revoke access after the allowed duration
setTimeout(() => {
revoke();
console.log("[Access revoked] All further access will throw TypeError");
}, durationMs);
return proxy;
}
const sensitiveData = { apiKey: "secret-key-123", userId: 42 };
const timedProxy = createTimedAccess(sensitiveData, 5000);
console.log(timedProxy.apiKey); // OK: "[Access] apiKey at ..." + "secret-key-123"
// After 5 seconds:
// timedProxy.apiKey; // TypeError: Cannot perform 'get' on a revoked proxy
// apply trap: function call logging and memoization
function memoize(fn) {
const cache = new Map();
return new Proxy(fn, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`[cache hit] ${key}`);
return cache.get(key);
}
const result = Reflect.apply(target, thisArg, args);
cache.set(key, result);
return result;
},
});
}
const expensiveCalc = memoize((n) => n ** 2);
expensiveCalc(10); // computed: 100
expensiveCalc(10); // [cache hit] [10] -> 100
Mironsoft
Advanced JavaScript architecture and reactive system design
Building a reactive data architecture or a validation layer?
We implement Proxy-based validation layers, reactive state systems and AOP logging infrastructure for scalable JavaScript architectures.
Architecture consulting
Applying Proxy for validation, reactivity and AOP in existing projects
Implementation
Reactive data systems, memoization layers and logging proxies for Node.js and the browser
Review
Code review for existing Proxy implementations covering invariants and performance
10. Summary
JavaScript Proxy and Reflect together form the most powerful meta-programming tool in the language. Proxy lets you intercept every fundamental object operation: reading, writing, deleting, enumerating, function calls and construction. Reflect exposes the default behavior of each of these operations as a first-class function and is the correct way to pass the default handling along inside a trap, especially because of correct receiver handling for class getters.
The practical use cases are diverse: validation layers without manual checks, reactive data systems like the ones in Vue 3 and MobX, revocable proxies for time-limited resource access, memoization without wrapper functions, AOP logging and virtual objects. Anyone who knows the 13 traps and the Proxy invariants can elegantly solve nearly any cross-cutting concern in JavaScript with a Proxy, transparently for the consuming code and without changes to the business logic.
JavaScript Proxy and Reflect: the essentials at a glance
Proxy
A stand-in placed in front of objects. 13 traps for all fundamental operations. Selective: only implemented traps are intercepted.
Reflect
Default behavior as a function. Always use Reflect.get/set/etc. with receiver inside traps for correct this binding on class getters.
Use cases
Validation, reactivity (Vue 3, MobX), memoization, AOP logging, revocable access, virtual objects, dependency injection.
Invariants
Traps must not violate the Proxy invariants. Example: get must not lie for non-configurable/non-writable properties.