Back to Articles
20 min read

JavaScript Meta-Programming: Proxies, Reflect, and Dynamic Introspection

Meta-programming is the capability of code to analyze and manipulate its own structure and behavior. This article explores the cutting edge of JavaScript introspection. We dissect the Proxy object—the engine behind modern reactivity systems like Vue 3—and the Reflect API, while drawing hard lines around legacy pitfalls like `eval` and the `with` statement.

Proxies and Reflect

Proxy Object

A Proxy wraps an object and intercepts fundamental operations (get, set, delete, etc.) through handler functions called traps, enabling powerful meta-programming patterns like validation, logging, and virtualization.

const target = { name: 'Alice', age: 30 }; const handler = { get(target, prop) { console.log(`Accessing: ${prop}`); return target[prop]; } }; const proxy = new Proxy(target, handler); proxy.name; // Logs "Accessing: name", returns "Alice" // Proxy sits between code and object // ┌──────────┐ ┌─────────┐ ┌────────┐ // │ Code │────▶│ Proxy │────▶│ Target │ // └──────────┘ │(Handler)│ └────────┘ // └─────────┘

Proxy Handlers (Traps)

Handler traps are methods that intercept specific operations on the target object; there are 13 traps corresponding to internal object methods, each receiving relevant arguments and controlling the operation's behavior.

┌────────────────────┬────────────────────────────────────────┐
│ Trap               │ Intercepts                             │
├────────────────────┼────────────────────────────────────────┤
│ get                │ property read                          │
│ set                │ property write                         │
│ has                │ 'in' operator                          │
│ deleteProperty     │ 'delete' operator                      │
│ apply              │ function call                          │
│ construct          │ 'new' operator                         │
│ getOwnPropertyDesc │ Object.getOwnPropertyDescriptor        │
│ defineProperty     │ Object.defineProperty                  │
│ getPrototypeOf     │ Object.getPrototypeOf                  │
│ setPrototypeOf     │ Object.setPrototypeOf                  │
│ ownKeys            │ Object.keys, for...in                  │
│ isExtensible       │ Object.isExtensible                    │
│ preventExtensions  │ Object.preventExtensions               │
└────────────────────┴────────────────────────────────────────┘

get Trap

The get trap intercepts property reads, receiving target, property, and receiver (the proxy itself); it's useful for default values, computed properties, and access logging.

const handler = { get(target, prop, receiver) { if (prop in target) { return target[prop]; } return `Property '${prop}' not found`; // Default value } }; const proxy = new Proxy({ a: 1 }, handler); proxy.a; // 1 proxy.b; // "Property 'b' not found"

set Trap

The set trap intercepts property assignments, receiving target, property, value, and receiver; must return true for success or false/throw for failure—perfect for validation and change tracking.

const validator = { set(target, prop, value) { if (prop === 'age') { if (!Number.isInteger(value) || value < 0) { throw new TypeError('Age must be a positive integer'); } } target[prop] = value; return true; // Indicate success } }; const person = new Proxy({}, validator); person.age = 30; // OK person.age = -5; // TypeError! person.age = 'old'; // TypeError!

has Trap

The has trap intercepts the in operator, receiving target and property; returns boolean to indicate whether the property should appear to exist, useful for hiding private properties.

const handler = { has(target, prop) { if (prop.startsWith('_')) { return false; // Hide "private" properties } return prop in target; } }; const obj = new Proxy({ _secret: 42, public: 1 }, handler); 'public' in obj; // true '_secret' in obj; // false (hidden!) obj._secret; // 42 (still accessible via get)

deleteProperty Trap

The deleteProperty trap intercepts the delete operator, receiving target and property; returns boolean indicating success, enabling protection of certain properties from deletion.

const handler = { deleteProperty(target, prop) { if (prop.startsWith('_')) { throw new Error(`Cannot delete private property: ${prop}`); } delete target[prop]; return true; } }; const obj = new Proxy({ _id: 1, name: 'test' }, handler); delete obj.name; // true, deleted delete obj._id; // Error: Cannot delete private property

apply Trap

The apply trap intercepts function calls, receiving target, thisArg, and argumentsList; only works when the target is a function, perfect for wrapping, logging, or modifying function behavior.

function sum(a, b) { return a + b; } const handler = { apply(target, thisArg, args) { console.log(`Called with: ${args}`); const result = target.apply(thisArg, args); console.log(`Result: ${result}`); return result; } }; const proxiedSum = new Proxy(sum, handler); proxiedSum(1, 2); // Logs: "Called with: 1,2", "Result: 3" // Returns: 3

construct Trap

The construct trap intercepts the new operator, receiving target, args, and newTarget; must return an object, enabling customization of instance creation for constructor functions and classes.

class User { constructor(name) { this.name = name; } } const handler = { construct(target, args, newTarget) { console.log(`Creating instance with: ${args}`); const instance = new target(...args); instance.createdAt = Date.now(); // Add property return instance; } }; const ProxiedUser = new Proxy(User, handler); const user = new ProxiedUser('Alice'); // user = { name: 'Alice', createdAt: 1699... }

Other Traps

Remaining traps handle prototype operations (getPrototypeOf, setPrototypeOf), property descriptors (getOwnPropertyDescriptor, defineProperty), extensibility (isExtensible, preventExtensions), and key enumeration (ownKeys).

const handler = { // Control Object.keys() / for...in ownKeys(target) { return Object.keys(target).filter(k => !k.startsWith('_')); }, // Control Object.getOwnPropertyDescriptor getOwnPropertyDescriptor(target, prop) { if (prop.startsWith('_')) return undefined; return Object.getOwnPropertyDescriptor(target, prop); } }; const obj = new Proxy({ _private: 1, public: 2 }, handler); Object.keys(obj); // ['public'] (_private hidden)

Revocable Proxies

Proxy.revocable() creates a proxy with a revoke() function that permanently disables the proxy; after revocation, any operation on the proxy throws a TypeError—useful for temporary access grants.

const target = { secret: 'data' }; const { proxy, revoke } = Proxy.revocable(target, {}); proxy.secret; // 'data' (works) revoke(); // Disable the proxy proxy.secret; // TypeError: Cannot perform 'get' on a revoked proxy // Use case: Timed access const { proxy: tempProxy, revoke: revokeAccess } = Proxy.revocable(sensitiveData, {}); setTimeout(revokeAccess, 60000); // Revoke after 1 minute

Proxy Use Cases

Proxies enable validation, default values, reactive programming (Vue.js), access control, logging/debugging, API virtualization, negative array indices, and implementing observable patterns.

// Negative array indices (Python-like) function createNegativeArray(arr) { return new Proxy(arr, { get(target, prop) { const index = Number(prop); if (Number.isInteger(index) && index < 0) { return target[target.length + index]; } return target[prop]; } }); } const arr = createNegativeArray([1, 2, 3, 4, 5]); arr[-1]; // 5 arr[-2]; // 4 // Observable pattern function observable(target, callback) { return new Proxy(target, { set(target, prop, value) { const old = target[prop]; target[prop] = value; callback(prop, old, value); return true; } }); }

Reflect API

Reflect is a built-in object providing methods that mirror Proxy traps, offering a cleaner way to perform default object operations; unlike Object methods, Reflect methods return boolean status instead of throwing.

const obj = { a: 1 }; // Old way (inconsistent) Object.defineProperty(obj, 'b', { value: 2 }); // throws on failure // Reflect way (consistent, returns boolean) Reflect.defineProperty(obj, 'c', { value: 3 }); // returns true/false // Perfect pairing with Proxy const proxy = new Proxy(obj, { get(target, prop, receiver) { console.log(`Getting ${prop}`); return Reflect.get(target, prop, receiver); // Default behavior } });

Reflect Methods

Reflect provides 13 static methods corresponding to each Proxy trap, including get, set, has, deleteProperty, apply, construct, defineProperty, getOwnPropertyDescriptor, getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, and ownKeys.

const obj = { x: 1 }; Reflect.get(obj, 'x'); // 1 Reflect.set(obj, 'y', 2); // true Reflect.has(obj, 'x'); // true Reflect.deleteProperty(obj, 'x'); // true Reflect.ownKeys(obj); // ['y'] function fn(a, b) { return a + b; } Reflect.apply(fn, null, [1, 2]); // 3 class Foo { constructor(x) { this.x = x; } } Reflect.construct(Foo, [42]); // Foo { x: 42 }

Reflect vs Object Methods

Reflect methods are preferred in Proxy handlers because they return status booleans (vs throwing), accept a receiver parameter for correct this binding, and have 1:1 correspondence with Proxy traps.

┌───────────────────────┬────────────────────┬────────────────────┐
│ Operation             │ Object             │ Reflect            │
├───────────────────────┼────────────────────┼────────────────────┤
│ Define property       │ Throws on fail     │ Returns false      │
│ Delete property       │ Returns bool       │ Returns bool       │
│ Get prototype         │ Throws for null    │ Works consistently │
│ Has property          │ 'in' operator      │ Returns bool       │
│ Receiver support      │ No                 │ Yes                │
│ Trap correspondence   │ Partial            │ 1:1                │
└───────────────────────┴────────────────────┴────────────────────┘

// Receiver importance
const parent = { get value() { return this.x; } };
const child = Object.create(parent);
child.x = 42;

Reflect.get(parent, 'value', child);  // 42 (correct this)
parent.value;                          // undefined

Meta-programming

Property Attributes

Every property has attributes: value, writable (can change value), enumerable (appears in for...in), and configurable (can delete/modify attributes); accessor properties use get/set instead of value/writable.

const obj = {}; Object.defineProperty(obj, 'constant', { value: 42, writable: false, // Cannot change enumerable: true, // Shows in Object.keys() configurable: false // Cannot delete or reconfigure }); Object.defineProperty(obj, 'computed', { get() { return this._val * 2; }, set(v) { this._val = v; }, enumerable: true, configurable: true }); Object.getOwnPropertyDescriptor(obj, 'constant'); // { value: 42, writable: false, enumerable: true, configurable: false }

Object Introspection

Introspection allows examining object structure at runtime using methods like Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertyDescriptors(), Object.getPrototypeOf(), and instanceof/typeof operators.

const obj = { a: 1, b: 2 }; Object.defineProperty(obj, 'c', { value: 3, enumerable: false }); Object.keys(obj); // ['a', 'b'] (enumerable only) Object.getOwnPropertyNames(obj); // ['a', 'b', 'c'] (all own) Object.getOwnPropertyDescriptors(obj); // All descriptors Object.getPrototypeOf(obj); // Object.prototype Object.isExtensible(obj); // true Object.isFrozen(obj); // false Object.isSealed(obj); // false typeof obj; // 'object' obj instanceof Object; // true obj.constructor; // [Function: Object]

Dynamic Property Access

JavaScript allows accessing and manipulating properties dynamically using bracket notation, computed property names, and Reflect methods—essential for generic utilities and metaprogramming.

const prop = 'dynamicKey'; const obj = { [prop]: 'value', // Computed property name ['method' + 1]() {} // Computed method name }; obj[prop]; // 'value' obj['method1']; // function // Dynamic manipulation function setPath(obj, path, value) { const keys = path.split('.'); const last = keys.pop(); const target = keys.reduce((o, k) => o[k] ??= {}, obj); target[last] = value; } const data = {}; setPath(data, 'user.profile.name', 'Alice'); // { user: { profile: { name: 'Alice' } } }

eval (and Why to Avoid It)

eval() executes a string as JavaScript code, but it's a security risk (code injection), breaks optimizations, makes debugging difficult, and has scope issues in strict mode—use Function constructor, JSON.parse, or other alternatives instead.

// DANGEROUS - never use with user input! eval('console.log("Hello")'); // Works but risky eval('const x = 1'); // Creates variable (non-strict) // Security risk example: const userInput = '"); deleteDatabase(); ("'; eval('console.log("' + userInput + '")'); // Code injection! // Alternatives: JSON.parse('{"a":1}'); // For JSON new Function('a', 'b', 'return a + b'); // For dynamic functions window[functionName](); // For dynamic calls

Function Constructor

The Function constructor creates functions from strings at runtime; it's safer than eval() because it only accesses global scope, but still poses security risks with untrusted input and breaks optimizations.

// Syntax: new Function(arg1, arg2, ..., functionBody) const add = new Function('a', 'b', 'return a + b'); add(1, 2); // 3 // Only accesses global scope (safer than eval) const x = 10; const fn = new Function('return x'); fn(); // ReferenceError: x is not defined (doesn't see local x) // Use case: Dynamic formula evaluation function createCalculator(formula) { return new Function('x', 'y', `return ${formula}`); } const calc = createCalculator('x * 2 + y'); calc(3, 4); // 10

with Statement (Deprecated)

The with statement extended scope chain with an object's properties, but it's deprecated and forbidden in strict mode because it creates ambiguity, prevents optimizations, and makes code unpredictable—never use it.

// DON'T USE - shown for awareness only const obj = { a: 1, b: 2 }; with (obj) { console.log(a + b); // 3 (accesses obj.a, obj.b) c = 3; // Creates global variable or obj.c? Ambiguous! } // Problems: // 1. Ambiguity: Is 'x' from obj, local scope, or global? // 2. Performance: Engine can't optimize variable lookups // 3. Security: Unpredictable behavior // Modern alternatives: const { a, b } = obj; // Destructuring console.log(a + b);

Symbols for Meta-programming

Well-known Symbols allow customizing object behavior for built-in operations: Symbol.iterator for iteration, Symbol.toStringTag for Object.prototype.toString, Symbol.toPrimitive for type coercion, and many others.

const obj = { // Custom iteration *[Symbol.iterator]() { yield 1; yield 2; yield 3; }, // Custom string tag get [Symbol.toStringTag]() { return 'MyObject'; }, // Custom type coercion [Symbol.toPrimitive](hint) { if (hint === 'number') return 42; if (hint === 'string') return 'hello'; return true; // default } }; [...obj]; // [1, 2, 3] Object.prototype.toString.call(obj); // '[object MyObject]' +obj; // 42 `${obj}`; // 'hello' // Other useful symbols: // Symbol.hasInstance - customize instanceof // Symbol.isConcatSpreadable - array concat behavior // Symbol.species - constructor for derived objects // Symbol.match/replace/search/split - string method behavior