JavaScript Proxy and Reflect: A Practical Guide to Metaprogramming

JavaScript Proxy and Reflect: A Practical Guide to Metaprogramming

What Metaprogramming Actually Means

Metaprogramming is code that operates on other code. Instead of writing logic that processes data, you write logic that intercepts and modifies how objects behave. JavaScript’s Proxy and Reflect APIs are the primary tools for this, and despite being available since ES2015, most developers still haven’t used them in production.

That’s a missed opportunity. Proxies power the reactivity systems in Vue 3, drive validation libraries, enable ORM magic, and make debugging complex state trivial. Once you understand the pattern, you’ll find uses for it everywhere.

Proxy Fundamentals

A Proxy wraps an object and intercepts operations on it through traps — handler methods that fire when you get, set, delete, or otherwise interact with the object’s properties.

const handler = {
  get(target, property, receiver) {
    console.log(`Reading property: ${String(property)}`);
    return Reflect.get(target, property, receiver);
  },
  set(target, property, value, receiver) {
    console.log(`Setting ${String(property)} = ${value}`);
    return Reflect.set(target, property, value, receiver);
  }
};

const user = new Proxy({ name: 'Alice', age: 30 }, handler);

user.name;      // Logs: "Reading property: name"
user.age = 31;  // Logs: "Setting age = 31"

The three arguments to the get trap are always the same: target (the original object), property (the property name as a string or Symbol), and receiver (the proxy itself, or an object inheriting from it).

Why Reflect Exists

You’ll notice I used Reflect.get() and Reflect.set() inside the handlers instead of directly accessing target[property]. This matters more than it looks.

Reflect provides methods that mirror every Proxy trap. Using Reflect ensures correct behavior in edge cases that direct access misses:

  • Prototype chain handlingReflect.get(target, prop, receiver) correctly resolves getters defined on a prototype, using the receiver as the this value. Direct access via target[prop] would use the wrong this.
  • Return valuesReflect.set() returns a boolean indicating success. Directly assigning target[prop] = value doesn’t tell you if the property was frozen or non-writable.
  • Consistency — Every Proxy trap has a matching Reflect method. Using Reflect as the default forwarding mechanism keeps your code uniform and predictable.

My rule of thumb: always use Reflect inside Proxy handlers. Direct access works in simple cases but breaks in subtle ways with inheritance and property descriptors.

Practical Pattern: Validation Proxy

This is the most immediately useful pattern. Instead of scattering validation checks across your codebase, put them in one place — the proxy handler.

function createValidatedObject(schema) {
  return new Proxy({}, {
    set(target, prop, value) {
      if (!(prop in schema)) {
        throw new Error(`Unknown property: ${prop}`);
      }

      const validator = schema[prop];
      if (!validator(value)) {
        throw new TypeError(
          `Invalid value for ${prop}: ${JSON.stringify(value)}`
        );
      }

      return Reflect.set(target, prop, value);
    }
  });
}

// Define your schema
const userSchema = {
  name: (v) => typeof v === 'string' && v.length > 0,
  age: (v) => Number.isInteger(v) && v >= 0 && v  typeof v === 'string' && v.includes('@')
};

const user = createValidatedObject(userSchema);
user.name = 'Bob';           // Works
user.age = 25;               // Works
user.age = -5;               // Throws: Invalid value for age
user.nickname = 'Bobby';     // Throws: Unknown property: nickname

This pattern is especially powerful for configuration objects. Instead of finding out about an invalid config value deep in your application code, the proxy catches it the moment it’s set.

Practical Pattern: Observable Objects

Need to react when a property changes? Proxies make this straightforward.

function observable(target, onChange) {
  return new Proxy(target, {
    set(obj, prop, value, receiver) {
      const oldValue = obj[prop];
      const result = Reflect.set(obj, prop, value, receiver);

      if (result && oldValue !== value) {
        onChange(prop, value, oldValue);
      }

      return result;
    },
    deleteProperty(obj, prop) {
      const oldValue = obj[prop];
      const result = Reflect.deleteProperty(obj, prop);

      if (result) {
        onChange(prop, undefined, oldValue);
      }

      return result;
    }
  });
}

// Usage
const state = observable({ count: 0, name: 'App' }, (prop, newVal, oldVal) => {
  console.log(`${prop} changed: ${oldVal} → ${newVal}`);
});

state.count = 1;  // Logs: "count changed: 0 → 1"
state.count = 1;  // No log — value didn't change
delete state.name; // Logs: "name changed: App → undefined"

This is essentially what Vue 3’s reactivity system does under the hood (with significantly more optimization). When you write reactive({ count: 0 }) in Vue, it creates a Proxy that tracks which components read which properties and re-renders them when those properties change.

Practical Pattern: Default Values

Tired of checking if (config.timeout !== undefined) { ... } everywhere? A Proxy can provide defaults for missing properties.

function withDefaults(target, defaults) {
  return new Proxy(target, {
    get(obj, prop, receiver) {
      if (prop in obj) {
        return Reflect.get(obj, prop, receiver);
      }
      return prop in defaults ? defaults[prop] : undefined;
    }
  });
}

const config = withDefaults(
  { apiUrl: 'https://api.example.com' },
  { timeout: 5000, retries: 3, debug: false }
);

console.log(config.apiUrl);  // 'https://api.example.com' (from target)
console.log(config.timeout); // 5000 (from defaults)
console.log(config.debug);   // false (from defaults)
console.log(config.missing); // undefined

Practical Pattern: Access Logging for Debugging

When debugging a complex state object, it helps to know exactly when and where properties are being read or modified. A logging proxy wraps your state during development.

function loggingProxy(target, label = 'Object') {
  return new Proxy(target, {
    get(obj, prop, receiver) {
      if (typeof prop === 'string') {
        console.trace(`[${label}] GET ${prop}`);
      }
      return Reflect.get(obj, prop, receiver);
    },
    set(obj, prop, value, receiver) {
      console.trace(`[${label}] SET ${prop} =`, value);
      return Reflect.set(obj, prop, value, receiver);
    }
  });
}

// Wrap your state during debugging
const state = loggingProxy(appState, 'AppState');

Using console.trace() instead of console.log() gives you the full call stack, so you can see exactly which function is reading or writing the property.

The Full List of Proxy Traps

Most developers only use get and set, but Proxy supports 13 traps:

TrapIntercepts
getProperty access: obj.prop
setProperty assignment: obj.prop = val
hasThe in operator: 'prop' in obj
deletePropertyThe delete operator
applyFunction calls (when proxying a function)
constructnew operator
getOwnPropertyDescriptorObject.getOwnPropertyDescriptor()
definePropertyObject.defineProperty()
getPrototypeOfObject.getPrototypeOf()
setPrototypeOfObject.setPrototypeOf()
isExtensibleObject.isExtensible()
preventExtensionsObject.preventExtensions()
ownKeysObject.keys(), for...in

Common Mistakes

1. Forgetting to Return From set()

The set trap must return true to indicate success. If you forget the return value, strict mode will throw a TypeError. Reflect.set() handles this automatically — another reason to use it.

2. Proxying in Hot Paths

Proxies add overhead to every property access. On objects accessed thousands of times per frame (game loops, real-time calculations), that overhead adds up. Profile before proxying objects in performance-critical code.

3. Breaking Identity Checks

A proxied object is not === to the original. new Proxy(obj, {}) !== obj. If your code relies on identity checks (like a WeakMap key or a Set membership), be careful about where you introduce proxies.

4. Infinite Recursion in Handlers

Accessing properties on the proxy inside the handler triggers the handler again. Always operate on target (via Reflect), never on receiver or the proxy itself.

Best Practices

  • Use Reflect in every handler. It’s the safe default. Direct access works until it doesn’t.
  • Keep handlers focused. Each proxy should do one thing: validation, logging, default values, or observation. Don’t combine everything into one mega-handler.
  • Use Proxy.revocable() for temporary access. It creates a proxy you can later disable: const { proxy, revoke } = Proxy.revocable(target, handler);. After calling revoke(), any access to the proxy throws.
  • Don’t proxy primitives. Proxy only works with objects and functions. If you need reactive primitives, wrap them in an object first.
  • Consider performance trade-offs. Proxies are great for developer-facing APIs (config objects, state containers) but avoid them on data structures in tight loops.

Wrapping Up

Proxy and Reflect are power tools. They let you intercept fundamental object operations and inject custom behavior — validation, observation, logging, default values — without modifying the object itself. They’re the foundation of modern reactivity systems and are well worth mastering.

Start with one pattern (validation is the easiest win) and expand from there. Once you see how much boilerplate a well-placed Proxy eliminates, you’ll find uses for it across your codebase.

FAQ

Are Proxies supported in all browsers?

Yes. Proxy and Reflect have been supported in all major browsers since 2016 (Chrome 49, Firefox 18, Safari 10, Edge 12). They cannot be polyfilled — the behavior they implement is fundamental to the language engine — so legacy IE support is impossible.

Do Proxies work with classes?

Yes. You can proxy class instances. A common pattern is returning a Proxy from a constructor: return new Proxy(this, handler). This makes every instance observable or validated automatically.

How does Vue 3 use Proxies?

Vue 3’s reactive() wraps objects in a Proxy with get and set traps. The get trap records which component is accessing which property (dependency tracking). The set trap triggers re-renders for components that depend on the changed property. This replaced Vue 2’s Object.defineProperty() approach, which couldn’t detect new property additions.

Can I use Proxy with arrays?

Yes. Arrays are objects, so Proxy works with them. The set trap fires for index assignments (arr[0] = 'new') and for the length property. Push, pop, splice, and other methods also trigger the set and deleteProperty traps as they modify the array internally.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment