JavaScript Proxy and Reflect API: Meta-Programming Made Simple

deep-diveAugust 10, 2026· 8 min read

Vue 3 uses it for reactivity. MobX uses it for observable state. Immer uses it for immutable updates. The Proxy API is one of the most powerful features ever added to JavaScript — yet most developers have never written one from scratch.

If you've ever wanted to intercept property access, validate object mutations, log every get/set operation, or build a reactive system without compile-time magic, Proxies are the answer.

Let's break them down — from the basics to production-grade patterns.

What is a Proxy?

A Proxy is an object that wraps another object (the "target") and lets you intercept fundamental operations on it — reading properties, writing properties, deleting properties, checking if a property exists, and more.

Think of it as middleware for objects. Every operation passes through your handler before reaching (or not reaching) the underlying data.

const user = { name: 'Divyam', role: 'admin' };

const proxiedUser = new Proxy(user, {
  get(target, prop) {
    console.log(`Reading ${String(prop)}`);
    return target[prop];
  },
  set(target, prop, value) {
    console.log(`Writing ${String(prop)} = ${value}`);
    target[prop] = value;
    return true; // required: signal success
  },
});

proxiedUser.name;       // logs: "Reading name"
proxiedUser.age = 30;   // logs: "Writing age = 30"

The original user object is untouched in structure — but every access now flows through your handler functions.

The 13 Trap Handlers

Proxy handlers define "traps" — methods that intercept specific operations. Here are the ones you'll actually use:

TrapInterceptsExample Use Case
getobj.propComputed properties, access logging
setobj.prop = xValidation, reactivity triggers
has'prop' in objHiding internal properties
deletePropertydelete obj.propPrevent deletion of keys
ownKeysObject.keys(obj)Filtering enumerable keys
getOwnPropertyDescriptorObject.getOwnPropertyDescriptorMaking props non-configurable
definePropertyObject.definePropertyControlling property definitions
applyfunction() callWrapping function behaviour
constructnew Class()Validating constructor args

The less common ones (preventExtensions, isExtensible, getPrototypeOf, setPrototypeOf) deal with prototype manipulation and are rarely needed in day-to-day work.

The apply Trap — Function Middleware

The apply trap lets you wrap function calls. This is how you'd build a memoisation wrapper or a debounce utility:

function memoize(fn) {
  const cache = new Map();
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args);
      if (cache.has(key)) {
        return cache.get(key);
      }
      const result = Reflect.apply(target, thisArg, args);
      cache.set(key, result);
      return result;
    },
  });
}

const slowFib = memoize(function fib(n) {
  return n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
});

slowFib(40); // instant — cached after first call

The construct Trap — Validating Instantiation

const SafeDate = new Proxy(Date, {
  construct(target, args) {
    if (args.length > 0 && typeof args[0] === 'string') {
      const d = new target(...args);
      if (isNaN(d.getTime())) {
        throw new TypeError(`Invalid date string: ${args[0]}`);
      }
    }
    return Reflect.construct(target, args);
  },
});

new SafeDate('2026-08-10'); // works
new SafeDate('not-a-date'); // throws TypeError

Enter Reflect

You'll notice I used Reflect.apply and Reflect.construct in the examples above. Reflect is Proxy's companion API — it provides the default behaviour for every trap.

Every Proxy trap has a matching Reflect method with the same arguments:

const handler = {
  get(target, prop, receiver) {
    console.log(`GET ${String(prop)}`);
    return Reflect.get(target, prop, receiver); // forwards to default behaviour
  },
  set(target, prop, value, receiver) {
    console.log(`SET ${String(prop)} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  },
};

Why Reflect Matters

Without Reflect, you'd write return target[prop] in your get trap. That works — until the target has a getter that uses this, or the prototype chain matters. Reflect ensures the operation behaves exactly as it would without the proxy.

Consider this subtle bug:

const obj = {};
Object.defineProperty(obj, 'secret', {
  get() {
    return this === globalProxy ? 'matched' : 'nope';
  },
});

const globalProxy = new Proxy(obj, {
  get(target, prop) {
    return target[prop]; // ❌ `this` inside the getter is `target`, not `globalProxy`
  },
});

const fixedProxy = new Proxy(obj, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver); // ✅ `this` is `globalProxy`
  },
});

The receiver parameter is the object the property was originally accessed on. Passing it through to Reflect.get preserves correct this binding in getters. This is the kind of detail that separates a toy proxy from a production one.

Real-World Patterns

Let's build the patterns you'll actually use in production.

1. Reactive State (Vue-Style)

This is the fundamental idea behind Vue 3's reactivity system:

function reactive(obj) {
  const subscribers = new Set();

  function notify() {
    subscribers.forEach((fn) => fn());
  }

  return {
    proxy: new Proxy(obj, {
      get(target, prop) {
        track(this, prop);
        const value = Reflect.get(target, prop);
        return typeof value === 'object' && value !== null
          ? reactive(value).proxy
          : value;
      },
      set(target, prop, value) {
        const oldValue = target[prop];
        if (oldValue === value) return true;
        target[prop] = value;
        notify();
        return true;
      },
    }),
    subscribe(fn) {
      subscribers.add(fn);
    },
  };
}

// Usage
const state = reactive({ count: 0, name: 'Clicks' });
state.subscribe(() => {
  document.title = `${state.proxy.count} ${state.proxy.name}`;
});

state.proxy.count++;  // document title updates automatically

Every framework's reactivity system — Vue 3, MobX, Solid.js — builds on this foundation. The Proxy intercepts the get (to register dependencies) and the set (to trigger updates).

2. Deep Property Validation

function validated(obj, schema) {
  return new Proxy(obj, {
    set(target, prop, value) {
      if (prop in schema) {
        const validator = schema[prop];
        if (!validator(value)) {
          throw new TypeError(`Invalid value for "${String(prop)}": ${value}`);
        }
      }
      target[prop] = value;
      return true;
    },
  });
}

const config = validated(
  { port: 3000, host: 'localhost' },
  {
    port: (v) => typeof v === 'number' && v > 0 && v < 65536,
    host: (v) => typeof v === 'string' && v.length > 0,
  }
);

config.port = 8080;  // ✅
config.port = -1;    // ❌ throws TypeError
config.host = '';    // ❌ throws TypeError

3. Private Properties Without Closures

JavaScript class private fields (#private) are great, but if you need privacy on plain objects, Proxy has you covered:

function withObjectPrivacy(obj) {
  const privatePrefix = '_';
  return new Proxy(obj, {
    get(target, prop) {
      if (typeof prop === 'string' && prop.startsWith(privatePrefix)) {
        return undefined; // hides private properties from reads
      }
      return Reflect.get(target, prop);
    },
    has(target, prop) {
      if (typeof prop === 'string' && prop.startsWith(privatePrefix)) {
        return false; // hides from `in` checks
      }
      return Reflect.has(target, prop);
    },
    ownKeys(target) {
      return Reflect.ownKeys(target).filter(
        (key) => !key.toString().startsWith(privatePrefix)
      );
    },
  });
}

const api = withObjectPrivacy({
  _apiKey: 'sk-secret-123',
  endpoint: '/v1/data',
  fetch() {
    // internal code can still access this._apiKey
    return fetch(this.endpoint, {
      headers: { Authorization: `Bearer ${this._apiKey}` },
    });
  },
});

console.log(api._apiKey);           // undefined
console.log('_apiKey' in api);       // false
console.log(Object.keys(api));       // ['endpoint', 'fetch']

4. Negative Array Indices

Python lets you do arr[-1] to get the last element. JavaScript doesn't — but with Proxies:

function negativeIndexArray(arr) {
  return new Proxy(arr, {
    get(target, prop) {
      if (typeof prop === 'string') {
        const index = Number(prop);
        if (!isNaN(index) && index < 0) {
          return target[target.length + index];
        }
      }
      return Reflect.get(target, prop);
    },
  });
}

const arr = negativeIndexArray(['a', 'b', 'c', 'd']);
arr[-1]; // 'd'
arr[-2]; // 'c'

5. Automatic API Client

Generate HTTP client methods from a config object — no manual fetch wrapping:

function createApiClient(baseUrl, endpoints) {
  return new Proxy({}, {
    get(_, namespace) {
      return new Proxy({}, {
        get(_, method) {
          return (...args) => {
            const path = endpoints[namespace]?.[method];
            if (!path) throw new Error(`Unknown API: ${namespace}.${method}`);
            const [params, options] = args;
            const url = baseUrl + path(params);
            return fetch(url, options).then((r) => r.json());
          };
        },
      });
    },
  });
}

const api = createApiClient('https://api.example.com', {
  users: {
    getById: (p) => `/users/${p.id}`,
    list: () => `/users`,
  },
  posts: {
    getByUser: (p) => `/users/${p.id}/posts`,
  },
});

// Clean, discoverable API
await api.users.getById({ id: 42 });
await api.users.list();
await api.posts.getByUser({ id: 42 });

Proxy vs Object.defineProperty

Before Proxies (ES6), frameworks like Vue 2 used Object.defineProperty to achieve reactivity. Here's why Proxies won:

FeatureObject.definePropertyProxy
Detect new properties❌ Need manual $set call✅ Automatic
Detect array index changes
Detect delete operations❌ Need manual $delete
Performance overheadPer-property, applied oncePer-access, but engines optimise
Browser supportIE9+ES6+ (all modern browsers)

Vue 3's move from Object.defineProperty to Proxy eliminated an entire category of reactivity edge cases. You no longer need Vue.set() or $delete() — Proxies catch every mutation.

Performance Considerations

Proxies add overhead per property access. In practice, this is negligible for most applications, but there are guidelines:

  1. Don't proxy tight loops. If you're iterating 100,000 items, access the raw object directly.

  2. Proxy once, not deeply. Instead of making every nested object a proxy upfront (expensive), use lazy proxying — proxy nested objects only when they're accessed (like Vue 3 does).

  3. Avoid has and ownKeys traps in hot paths. These fire on operations like for...in loops and Object.keys(), which can be frequent.

  4. Revoke when done. If you create a proxy that should no longer be used, create it with Proxy.revocable:

const { proxy, revoke } = Proxy.revocable(sensitiveData, {
  get(target, prop) {
    return Reflect.get(target, prop);
  },
});

// Use it
console.log(proxy.secret);

// Later — permanently disable
revoke();

console.log(proxy.secret); // TypeError: illegal operation attempted on a revoked proxy

Common Pitfalls

Forgetting return true in set

The set trap must return true in strict mode to indicate success. Forgetting this throws a TypeError:

const p = new Proxy({}, {
  set(target, prop, value) {
    target[prop] = value;
    // forgot return true!
  },
});

p.x = 1; // TypeError: 'set' on proxy: trap returned falsish for property 'x'

JSON.stringify and Proxies

JSON.stringify iterates using ownKeys and getOwnPropertyDescriptor. If your proxy doesn't implement these traps correctly, serialisation can produce unexpected results:

const p = new Proxy({ a: 1, b: 2 }, {
  get(target, prop) {
    return Reflect.get(target, prop);
  },
});

JSON.stringify(p); // works — default traps are used

But if you customise ownKeys to filter properties, JSON.stringify will respect that filtering — which may or may not be what you want.

Proxying Class Instances

Proxying a class instance can break instanceof checks unless you implement getPrototypeOf:

class Animal {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} makes a sound`; }
}

const dog = new Animal('Rex');
const proxiedDog = new Proxy(dog, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
  },
});

proxiedDog instanceof Animal; // true (default getPrototypeOf works)
proxiedDog.speak();           // "Rex makes a sound"

This works because the default proxy behaviour forwards prototype checks. But if you add a getPrototypeOf trap, be careful to return the right prototype.

Key Takeaways

  • Proxy wraps an object and lets you intercept 13 fundamental operations — get, set, has, apply, construct, and more.
  • Reflect provides the default implementation for each trap. Always use Reflect.* methods instead of manually forwarding operations — it handles edge cases like this binding in getters.
  • Real-world use cases: reactivity systems (Vue 3, MobX), validation layers, private property patterns, API client generation, and memoisation.
  • Performance is fine for normal application code. Avoid proxying in tight loops, use lazy deep proxying, and consider Proxy.revocable for temporary wrappers.
  • Always return true from set traps in strict mode, or you'll get a TypeError.

The Proxy API is one of those features that seems niche until you build something with it — then you wonder how you ever lived without it. Start with the logging proxy (intercept every get and set), then work your way up to reactivity and validation patterns. The same primitives that power Vue 3 and MobX are available to you, right now, in every modern browser.

Donate