Skip to content

Proxy and Reflect

intermediate12 min read

Intercepting the Unseeable

What if you could intercept every property read, every write, every delete, every in check on an object — and run your own code instead? That's exactly what Proxy gives you. And Reflect gives you a clean way to do the "normal" thing after your interception. Together, they unlock metaprogramming — the ability to redefine how objects fundamentally behave.

And this isn't theoretical. Vue 3's entire reactivity system is built on Proxy. Libraries use it for validation, logging, memoization, and access control. Understanding Proxy is understanding how modern frameworks work under the hood.

Mental Model

Think of a Proxy as a security guard standing in front of an object (the "target"). Every time someone tries to access, modify, or interact with the target, the guard intercepts the attempt. The guard can allow it, deny it, modify it, or do something completely different. The Reflect API is the guard's manual — it describes the standard way to perform each operation, so the guard knows what "normal behavior" looks like.

The Basics

Let's start simple and build up.

const target = { name: "Alice", age: 30 };

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

const proxy = new Proxy(target, handler);

proxy.name;       // Logs: "Reading name", returns "Alice"
proxy.age = 31;   // Logs: "Writing age = 31"

The handler object defines traps — functions that intercept specific operations. There are 13 traps, one for each fundamental operation in the spec.

The 13 Traps

TrapInterceptsExample
getProperty readobj.prop, obj[key]
setProperty writeobj.prop = val
hasin operator"prop" in obj
deletePropertydelete operatordelete obj.prop
applyFunction callfn(), fn.call()
constructnew operatornew Fn()
getPrototypeOfObject.getPrototypeOf
setPrototypeOfObject.setPrototypeOf
isExtensibleObject.isExtensible
preventExtensionsObject.preventExtensions
getOwnPropertyDescriptorObject.getOwnPropertyDescriptor
definePropertyObject.defineProperty
ownKeysObject.keys, for...in

The Reflect API — The Default Behavior Manual

This is the part most tutorials rush through, but it's crucial. Every Proxy trap has a corresponding Reflect method that performs the default behavior. Always use Reflect to forward operations in traps:

// BAD — doesn't handle edge cases
get(target, prop) {
  return target[prop]; // Misses: receiver, prototype chain, getters
}

// GOOD — correct forwarding
get(target, prop, receiver) {
  return Reflect.get(target, prop, receiver);
}
Common Trap

Using target[prop] instead of Reflect.get(target, prop, receiver) inside a get trap breaks getter behavior. If the property is a getter, target[prop] sets this to target (the raw object), but Reflect.get with receiver sets this to the proxy. This matters when the getter accesses other properties — they should also go through the proxy, not bypass it:

const target = {
  _name: "Alice",
  get name() { return this._name; }
};

const proxy = new Proxy(target, {
  get(target, prop, receiver) {
    console.log(`trap: ${prop}`);
    return target[prop]; // BUG: this._name bypasses the proxy
    // return Reflect.get(target, prop, receiver); // CORRECT
  }
});

proxy.name;
// With target[prop]: only logs "trap: name" (._name access is not trapped)
// With Reflect.get: logs "trap: name" AND "trap: _name"

Real Use Case 1: Validation

function createValidated(schema) {
  return new Proxy({}, {
    set(target, property, value) {
      const validator = schema[property];
      if (validator && !validator(value)) {
        throw new TypeError(
          `Invalid value for ${property}: ${JSON.stringify(value)}`
        );
      }
      return Reflect.set(target, property, value);
    }
  });
}

const user = createValidated({
  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: -5
user.email = "bad";  // TypeError: Invalid value for email: "bad"

Real Use Case 2: Observable / Reactive (Vue 3 Pattern)

Now we're getting to the good stuff. This is basically the core of Vue 3's reactivity:

function reactive(target) {
  const subscribers = new Map();

  return new Proxy(target, {
    get(target, property, receiver) {
      // Track which properties are accessed during render
      trackDependency(subscribers, property);
      const value = Reflect.get(target, property, receiver);
      // If the value is an object, make it reactive too (deep reactivity)
      return typeof value === "object" && value !== null
        ? reactive(value)
        : value;
    },
    set(target, property, value, receiver) {
      const oldValue = target[property];
      const result = Reflect.set(target, property, value, receiver);
      if (oldValue !== value) {
        // Notify all watchers of this property
        triggerSubscribers(subscribers, property);
      }
      return result;
    }
  });
}
Why Vue 3 switched from Object.defineProperty to Proxy

Vue 2 used Object.defineProperty to make objects reactive. This had limitations:

  1. New properties: You couldn't detect when a new property was added (hence Vue.set())
  2. Array indices: Setting arr[index] = value couldn't be intercepted
  3. Array length: Changing arr.length wasn't reactive
  4. Performance: Every property needed its own getter/setter at initialization

Proxy solves all of these. It intercepts any operation on the object, including property addition, deletion, and in checks. Vue 3's reactivity is more complete and performs better because of this switch.

Real Use Case 3: Negative Array Indices

function createArray(...items) {
  return new Proxy(items, {
    get(target, property, receiver) {
      const index = Number(property);
      if (Number.isInteger(index) && index < 0) {
        return Reflect.get(target, String(target.length + index), receiver);
      }
      return Reflect.get(target, property, receiver);
    }
  });
}

const arr = createArray("a", "b", "c", "d");
arr[-1]; // "d"
arr[-2]; // "c"
arr[0];  // "a"

Real Use Case 4: Access Logging / Debugging

function withLogging(target, label = "Object") {
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (typeof prop === "string") {
        console.log(`[${label}] GET .${prop}`);
      }
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      console.log(`[${label}] SET .${prop} = ${JSON.stringify(value)}`);
      return Reflect.set(target, prop, value, receiver);
    },
    deleteProperty(target, prop) {
      console.log(`[${label}] DELETE .${prop}`);
      return Reflect.deleteProperty(target, prop);
    }
  });
}

The Performance Cost

Before you go wrapping everything in Proxies, you need to know this: proxies are not free. Every trapped operation has overhead:

// Benchmark comparison (approximate, V8)
const obj = { x: 1 };
const proxy = new Proxy(obj, { get: Reflect.get });

// Direct access: ~0.5ns per operation
// Proxy access: ~50ns per operation (100x slower)
When to avoid Proxy

Don't use Proxy on hot paths — tight loops, animation frames, or frequently accessed data in render functions. The 100x overhead is negligible for occasional access (UI events, configuration) but devastating in hot paths. Vue 3 mitigates this with careful caching and avoiding unnecessary reactive wrapping of primitives.

Proxy Invariants — The Rules You Can't Break

You might think a Proxy can do anything. It can't. The spec enforces invariants — rules that prevent proxies from lying about the target:

const target = {};
Object.defineProperty(target, "locked", {
  value: 42,
  writable: false,
  configurable: false
});

const proxy = new Proxy(target, {
  get() { return 99; } // Try to lie about the value
});

proxy.locked; // TypeError: proxy get handler returned 99 for
              // non-writable, non-configurable property "locked"

If the target has a non-writable, non-configurable property, the proxy's get trap must return the same value. This prevents proxies from making frozen objects appear mutable or hiding sealed properties.

Production Scenario: Type-Safe API Client

function createApiClient(baseUrl) {
  return new Proxy({}, {
    get(_, resource) {
      // api.users → fetch /users
      // api.posts → fetch /posts
      return {
        async list(params) {
          const url = new URL(`${baseUrl}/${resource}`);
          Object.entries(params || {}).forEach(([k, v]) =>
            url.searchParams.set(k, v)
          );
          const res = await fetch(url);
          return res.json();
        },
        async get(id) {
          const res = await fetch(`${baseUrl}/${resource}/${id}`);
          return res.json();
        },
        async create(data) {
          const res = await fetch(`${baseUrl}/${resource}`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data)
          });
          return res.json();
        }
      };
    }
  });
}

const api = createApiClient("https://api.example.com");
await api.users.list({ page: 1 });     // GET /users?page=1
await api.posts.get(42);               // GET /posts/42
await api.comments.create({ text: "hi" }); // POST /comments
What developers doWhat they should do
Using target[prop] instead of Reflect.get in traps
target[prop] bypasses the proxy for nested property access and breaks getter 'this' binding
Always use Reflect methods to forward operations — they handle receiver, getters, and prototype chains correctly
Using Proxy on hot paths (tight loops, animation frames)
Proxy trap invocation adds ~100x overhead compared to direct property access
Use Proxy for infrequent operations (config, validation, API clients). Use plain objects for hot paths.
Trying to make a proxy lie about frozen properties
The spec enforces invariants. Violating them throws TypeError to prevent a proxy from breaking object guarantees.
Respect invariants — proxy traps must be consistent with target's property descriptors
Forgetting to return true from set traps
Returning false or undefined from set causes TypeError in strict mode
The set trap must return true to indicate success (or the assignment throws TypeError in strict mode)
Quiz
Why must you pass 'receiver' to Reflect.get inside a get trap?
Quiz
What happens if a Proxy's get trap returns a different value for a non-writable, non-configurable property?
Quiz
How does Vue 3's reactivity detect NEW property additions, unlike Vue 2?
Key Rules
  1. 1Proxy intercepts 13 fundamental operations (get, set, has, delete, apply, construct, etc.) through handler traps
  2. 2Always use Reflect methods inside traps to perform the default operation — they handle receiver, getters, and prototype chains correctly
  3. 3Proxy has ~100x overhead per trapped operation — avoid on hot paths, use for config/validation/API patterns
  4. 4Proxy invariants prevent traps from lying about frozen or non-configurable properties — violations throw TypeError
  5. 5Vue 3 uses Proxy for reactivity — this is the mechanism that tracks dependencies and triggers re-renders