jsguides

Proxy and Reflect in JavaScript

JavaScript’s Proxy and Reflect APIs give your code the ability to intercept and customize fundamental object operations. A proxy wraps an object and catches actions like property access, assignment, deletion, and function invocation. With the Reflect API handling the default behavior, you can focus on adding only the extra logic you need. If you have ever wanted to validate data automatically, create lazy-loading properties, or build reactive data structures, proxy and reflect are the tools for the job.

What Is a Proxy?

A Proxy wraps an object and intercepts operations that would normally happen directly on that object. You define a handler object with “traps”, which are functions that get called when specific operations occur.

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

const handler = {
  get(target, prop, receiver) {
    console.log(`Getting ${prop}`);
    return target[prop];
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.name);  // Logs: Getting name, then returns "Alice"
console.log(proxy.age);  // Logs: Getting age, then returns 30

The handler receives three arguments: the target object, the property being accessed, and the receiver (which is the proxy itself unless inherited). This basic pattern opens up significant possibilities. You can intercept reads, writes, property checks, and even function calls. The get trap is the one you will reach for most often, so it is a natural place to start.

The get() Trap

The get trap fires whenever you read a property. It’s useful for creating computed properties, providing defaults, or implementing lazy initialization.

const user = {
  firstName: 'John',
  lastName: 'Doe'
};

const proxy = new Proxy(user, {
  get(target, prop) {
    if (prop === 'fullName') {
      return `${target.firstName} ${target.lastName}`;
    }
    return target[prop];
  }
});

console.log(proxy.firstName);  // "John"
console.log(proxy.fullName);  // "John Doe" - computed on access

The computed property example is clean, but the real value of get shows up when you want to protect callers from undefined values. Rather than sprinkling null checks everywhere, you can make the proxy supply a sensible fallback whenever a key is missing from the underlying object:

const config = new Proxy({}, {
  get(target, prop) {
    if (!(prop in target)) {
      console.warn(`Configuration "${prop}" not set, using default`);
      return 'default';
    }
    return target[prop];
  }
});

console.log(config.databaseUrl);  // Logs warning, returns "default"
config.databaseUrl = 'postgres://localhost';
console.log(config.databaseUrl); // "postgres://localhost"

So far we have only intercepted reads, but the real power of Proxy comes from combining traps. The set trap lets you enforce rules at the point of assignment. No separate validation step is required — every property write goes through the handler before it reaches the object. When validation lives in the proxy, callers cannot accidentally skip it:

The set() Trap

The set trap intercepts property assignments. This is ideal for validation, computed properties, or maintaining relationships between properties.

const person = {};

const validator = {
  set(target, prop, value) {
    if (prop === 'age') {
      if (typeof value !== 'number') {
        throw new TypeError('Age must be a number');
      }
      if (value < 0 || value > 150) {
        throw new RangeError('Age must be between 0 and 150');
      }
    }
    if (prop === 'email' && !value.includes('@')) {
      throw new Error('Invalid email format');
    }
    target[prop] = value;
    return true;
  }
};

const proxy = new Proxy(person, validator);

proxy.age = 30;      // Works
proxy.email = 'test@example.com';  // Works
proxy.age = 'thirty'; // Throws TypeError
proxy.age = 200;     // Throws RangeError

Validation is the obvious use, but set can also wire up reactive side effects. Every assignment becomes an event you can hook into without changing the caller’s code. This is the core idea behind observable state systems:

const state = {
  _value: 0,
  get value() { return this._value; },
  set value(v) { this._value = v; }
};

const handlers = {
  set(target, prop, value) {
    target[prop] = value;
    console.log(`State changed: ${prop} = ${value}`);
    return true;
  }
};

const proxy = new Proxy(state, handlers);
proxy.value = 42;  // Logs: State changed: value = 42

We have covered reading and writing properties. The has trap addresses a different question: what does the in operator report? This is useful when an object carries internal fields that callers should not know about. Rather than relying on naming conventions, the proxy can enforce the boundary programmatically:

The has() Trap

The has trap controls what happens with the in operator. You can hide properties or compute presence dynamically.

const api = {
  _secret: 'api-key-123',
  endpoints: ['users', 'posts', 'comments']
};

const proxy = new Proxy(api, {
  has(target, prop) {
    if (prop.startsWith('_')) {
      return false;  // Hide private properties
    }
    return prop in target;
  }
});

console.log('endpoints' in proxy);  // true
console.log('_secret' in proxy);    // false - hidden

Hiding properties is one application of has. The complementary operation is preventing deletion of data that your code depends on. That is where deleteProperty comes in:

The deleteProperty() Trap

Use this to intercept the delete operator. You can prevent deletion of protected properties or log deletions.

const protectedObj = {
  name: 'Protected',
  _internal: 'do not delete'
};

const proxy = new Proxy(protectedObj, {
  deleteProperty(target, prop) {
    if (prop.startsWith('_')) {
      console.warn(`Cannot delete protected property: ${prop}`);
      return false;
    }
    delete target[prop];
    return true;
  }
});

delete proxy.name;      // Works, deletes name
delete proxy._internal; // Logs warning, returns false

So far every trap has operated on plain objects. But Proxy works on functions too — apply gives you a way to wrap any function call with before-and-after logic, logging, or argument transformation. This is a natural fit for debugging and profiling:

The apply() Trap

The apply trap intercepts function calls. This enables function wrapping, argument transformation, and debouncing.

function multiply(a, b) {
  return a * b;
}

const traced = new Proxy(multiply, {
  apply(target, thisArg, args) {
    console.log(`Calling ${target.name} with args:`, args);
    const result = target.apply(thisArg, args);
    console.log(`Result: ${result}`);
    return result;
  }
});

const result = traced(3, 4);
// Logs: Calling multiply with args: [3, 4]
// Logs: Result: 12
// Returns: 12

Tracing is useful for diagnostics, but apply can also change what the function does. A classic example is wrapping a slow function with a cache so that repeated calls with the same arguments return instantly. The proxy intercepts every invocation, checks the cache, and delegates to the real function only when the result is not yet stored:

const 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 = target.apply(thisArg, args);
      cache.set(key, result);
      return result;
    }
  });
};

const expensive = memoize((n) => {
  console.log('Computing...');
  return n * n;
});

console.log(expensive(5));  // Logs "Computing...", returns 25
console.log(expensive(5));  // Returns 25 (cached)

Functions can be trapped with apply, and class constructors get their own interception point as well. The construct trap fires on the new operator, which lets you validate constructor arguments or wrap the resulting instance before it reaches the caller:

The construct() Trap

The construct trap handles the new operator. Use it to intercept object creation, validate arguments, or create factory patterns.

class Builder {
  constructor(type) {
    this.type = type;
  }
}

const proxy = new Proxy(Builder, {
  construct(target, args) {
    console.log(`Creating instance with:`, args);
    return new target(...args);
  }
});

const instance = new proxy('custom');
// Logs: Creating instance with: ["custom"]
// instance is a Builder instance with type = "custom"

We have covered individual property operations such as get, set, has, and delete. The ownKeys trap gives you control at a higher level: what keys appear when something iterates over the object. This is particularly helpful for wrapping objects that carry sensitive data alongside public fields, especially in the context of security-sensitive APIs:

The ownKeys() Trap

The ownKeys trap controls what properties show up in Object.keys(), for...in, and Object.getOwnPropertyNames(). Hide certain properties from enumeration.

const sensitiveData = {
  username: 'john',
  password: 'secret123',
  lastLogin: '2024-01-15'
};

const secure = new Proxy(sensitiveData, {
  ownKeys(target) {
    return Object.keys(target).filter(key => key !== 'password');
  }
});

console.log(Object.keys(secure));  // ["username", "lastLogin"]

Reflect API

The Reflect object provides static methods that correspond to the Proxy traps. These methods give you the default behavior for each operation, which is invaluable inside trap handlers.

const target = { a: 1 };

const proxy = new Proxy(target, {
  get(target, prop, receiver) {
    // Custom behavior
    console.log(`Accessing ${prop}`);
    // Default behavior - use Reflect
    return Reflect.get(target, prop, receiver);
  }
});

Reflect provides these methods:

MethodEquivalent
Reflect.get(obj, prop)obj[prop]
Reflect.set(obj, prop, value)obj[prop] = value
Reflect.has(obj, prop)prop in obj
Reflect.deleteProperty(obj, prop)delete obj[prop]
Reflect.ownKeys(obj)Object.getOwnPropertyNames(obj)
Reflect.getPrototypeOf(obj)Object.getPrototypeOf(obj)
Reflect.setPrototypeOf(obj, proto)Object.setPrototypeOf(obj, proto)
Reflect.isExtensible(obj)Object.isExtensible(obj)
Reflect.preventExtensions(obj)Object.preventExtensions(obj)
Reflect.getOwnPropertyDescriptor(obj, prop)Object.getOwnPropertyDescriptor(obj, prop)
Reflect.defineProperty(obj, prop, descriptor)Object.defineProperty(obj, prop, descriptor)
Reflect.apply(fn, thisArg, args)fn.apply(thisArg, args)
Reflect.construct(fn, args)new fn(...args)

The key advantage of Reflect is consistency. Instead of remembering different syntaxes for each operation, you have a unified API. Inside proxy traps, always use Reflect to delegate to the default behavior; it is cleaner and handles edge cases better.

Practical use cases

Observable Objects

Create objects that notify watchers when properties change:

function createObservable(target, onChange) {
  const handler = {
    set(obj, prop, value) {
      const oldValue = obj[prop];
      obj[prop] = value;
      onChange(prop, oldValue, value);
      return true;
    }
  };
  return new Proxy(target, handler);
}

const state = createObservable({ count: 0 }, (prop, oldVal, newVal) => {
  console.log(`${prop} changed: ${oldVal} -> ${newVal}`);
});

state.count = 1;   // Logs: count changed: 0 -> 1
state.count = 5;   // Logs: count changed: 1 -> 5

The observable pattern is powerful, but sometimes you need to grant temporary access that you can later revoke. Proxy.revocable creates a proxy that you can disable entirely. Once revoked, every operation on the proxy throws, which is useful for sandboxing or cleaning up subscriptions:

Revocable proxies

Create proxies that can be disabled:

const { proxy, revoke } = Proxy.revocable({
  secret: 'hidden'
}, {
  get(target, prop) {
    return target[prop];
  }
});

console.log(proxy.secret);  // "hidden"
revoke();
console.log(proxy.secret);  // TypeError: Cannot perform 'get' on a revoked Proxy

Revocable proxies give you a safety eject button: when the proxy has outlived its purpose, you can shut it down completely. Another common need is passive observation. Rather than changing behavior, you may simply want to record every property access for debugging. The next pattern does exactly that:

Property access logging

Debug property access in production without modifying source code:

const debugProxy = (obj) => new Proxy(obj, {
  get(target, prop, receiver) {
    const value = Reflect.get(target, prop, receiver);
    console.log(`GET ${String(prop)}:`, value);
    return value;
  },
  set(target, prop, value) {
    console.log(`SET ${String(prop)}:`, value);
    return Reflect.set(target, prop, value);
  }
});

const tracked = debugProxy({ x: 1 });
tracked.x = 2;
// Logs: GET x: 1
// Logs: SET x: 2

Invariants

Proxies enforce certain invariants that you cannot override. Violating these throws a TypeError:

  • Non-configurable properties cannot be deleted
  • Extensible targets cannot be made non-extensible through the proxy
  • Non-writable properties cannot have their values changed
  • A property cannot be made non-configurable if it doesn’t exist as a non-configurable on the target

These protections ensure that proxies maintain the basic guarantees JavaScript objects provide.

Keeping traps honest

Proxy traps are most reliable when they preserve the same basic behavior the original object would have had. That is why Reflect is such a good companion. It gives you the default action and keeps your custom logic focused on the extra behavior you actually need. A trap that ignores the default rules can create strange edge cases very quickly.

This is especially important when your proxy is part of a shared system. Other code will make ordinary assumptions about property access, assignment, and enumeration. If the proxy breaks those assumptions, bugs can look random even though the root cause is just a trap that went too far. A thin layer is usually safer than a clever one.

Where proxies fit best

Proxies are a strong choice when you need a cross-cutting behavior that would otherwise repeat across many properties. Validation, tracing, lazy loading, and access logging are good examples. They let you centralize the rule without rewriting every caller. That can make experiments and diagnostics much faster.

They are less helpful when the behavior is simple and local. If a plain getter, setter, or helper function can solve the problem cleanly, that is often easier for the next person to read. Proxy logic is powerful, but it also adds another layer that readers must keep in mind whenever they touch the object.

Performance and Debugging

Proxies can make a system harder to reason about when many operations are intercepted at once. Every trap adds overhead and more places where behavior can shift. That does not mean they are slow in every case, but it does mean you should treat them as a focused tool rather than a default architecture choice.

Debugging is easier when the trap logs are precise. Record the property name, the operation, and the value you observed or returned. That makes it much easier to trace the flow through a proxy-backed object. If the logs become noisy, reduce the number of traps or move the diagnostics behind a feature flag so normal usage stays quiet.

Proxy Scope

Good proxy design usually means keeping the scope narrow. If a proxy tries to manage every object in the app, it becomes hard to understand which behavior is native and which behavior is intercepted. A smaller scope keeps the effect obvious and makes it easier to spot when a trap is the source of an odd result.

That narrow scope is also useful for future refactors. If the proxy only wraps a single responsibility, it can be removed or replaced without changing the rest of the app. The proxy then acts like a layer of policy, not like the foundation of the object model.

Choosing proxy carefully

Proxy and Reflect are best treated as special-purpose tools. They are excellent when you need to observe or shape operations in one place, but they can make a codebase harder to read if they become the default way to express ordinary object behavior. A plain method or getter is often simpler when the behavior belongs directly on the object.

The cleanest proxy setups usually have a small target, a small set of traps, and a clear reason for existing. When those three pieces are true, the proxy can stay out of the way until it is needed and still provide a strong layer of control when the app requires it.

See Also