jsguides

The Proxy Design Pattern in JavaScript

The Proxy Design Pattern lets you intercept and control access to an object. Instead of accessing the target directly, you go through a proxy that can validate requests, lazy-load data, log operations, or redirect them elsewhere.

Proxy design pattern overview

The Proxy Pattern wraps an object with a surrogate that intercepts operations meant for the original. The wrapped object doesn’t know it’s being intercepted — it behaves normally, while the proxy layer handles the logic around each operation.

This pattern shows up constantly in JavaScript even if you don’t recognize it. Property accessors are a form of proxy. Getters and setters intercept reads and writes. ES6 Proxy formalizes this into a clean API.

You create a proxy design pattern implementation with two arguments: the target object and a handler that defines traps for each operation you want to intercept.

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

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

const proxy = new Proxy(target, handler);

console.log(proxy.name);
// Reading name
// Alice

The handler object holds trap functions. Each trap corresponds to a fundamental object operation like reading a property, writing to it, checking if a key exists, or deleting it. An empty handler passes everything through unchanged.

When to use the proxy pattern

The proxy design pattern fits scenarios where you need to control access to an object without changing its interface. Validation is the most common use case: you want every assignment to go through a check without relying on callers to do the right thing. Lazy initialization also benefits from proxies, since you can defer expensive setup until code actually needs the data.

Logging and debugging are natural fits because the proxy sees every operation. Rather than adding console statements throughout a codebase, you wrap the object once and every read, write, or method call gets logged automatically. This is especially useful when troubleshooting production issues where you can’t easily modify the original code.

The pattern also shows up in security contexts. Hiding private properties through has and ownKeys traps prevents accidental exposure. Revocable proxies take this further by letting you invalidate access entirely at runtime. If you need to terminate a session or revoke permissions mid-execution, the revoke function makes that straightforward.

What you’ll build

This tutorial walks through six practical examples. You’ll start with property validation and lazy initialization, then move into logging, private property hiding, and revocable access. Each example shows a real pattern you can adapt directly to your codebase.

Property validation

One of the most practical uses for a proxy is enforcing validation at the property level. Instead of sprinkling checks throughout your codebase, you centralize them in the handler. Any assignment that bypasses your validation layer will still hit the proxy’s set trap, so the rules apply everywhere.

const createValidatedUser = (initialData) => {
  return new Proxy(initialData, {
    set(target, prop, value, receiver) {
      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 address');
      }
      return Reflect.set(target, prop, value, receiver);
    }
  });
};

const user = createValidatedUser({ name: 'Bob' });
user.age = 35;        // Works
user.email = 'bob@example.com';  // Works
user.age = -5;       // Throws RangeError
user.email = 'invalid';  // Throws Error

This approach keeps validation logic in one place. The proxy guarantees that any assignment to age or email goes through your rules, even if code elsewhere tries to bypass them. If you need to validate other fields, add them to the handler; the pattern scales naturally.

Lazy initialization

Proxy lets you defer expensive initialization until a property is actually accessed. This is useful for objects that carry heavy state but might never be fully used. The connection stays closed until the first access; the expensive computation stays unevaluated until someone actually asks for the result.

const createLazyConnection = (config) => {
  let connection = null;

  return new Proxy({}, {
    get(target, prop) {
      if (prop === 'query' && !connection) {
        console.log('Establishing database connection...');
        connection = {
          query: (sql) => `Executing: ${sql}`,
          close: () => console.log('Connection closed')
        };
      }
      if (connection) {
        return connection[prop];
      }
      return undefined;
    }
  });
};

const db = createLazyConnection({ host: 'localhost' });
// No connection yet

db.query('SELECT * FROM users');  // Logs: Establishing database connection...
db.query('SELECT * FROM orders');  // Reuses existing connection

The connection only opens when you first touch the query property. Subsequent accesses reuse the same connection object. You can extend this pattern to cache responses, retry failed requests, or add timeout handling.

Logging and debugging

A proxy is a natural place to add logging without modifying the original object. This is especially valuable when debugging production issues where you can’t easily add console statements. The proxy intercepts every operation, so you get a complete picture of how the object is being used without touching the underlying code.

const createLoggedObject = (obj) => {
  return new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      if (typeof value === 'function') {
        console.log(`Method called: ${prop}`);
        return value.bind(target);
      }
      return value;
    },
    set(target, prop, value, receiver) {
      console.log(`Setting ${prop} to`, value);
      return Reflect.set(target, prop, value, receiver);
    }
  });
};

const counter = createLoggedObject({ count: 0, increment() { this.count++; } });
counter.increment();
counter.count = 10;
counter.increment();

// Output:
// Method called: increment
// Setting count to 10
// Method called: increment

The proxy wraps the target and logs every read and write. For methods, it binds this to the original target so the method still operates correctly. You can adapt this pattern to log to a file, send to a monitoring service, or filter by property name.

Hiding private properties

The has trap intercepts the in operator, and ownKeys controls what shows up in Object.keys(). Together they let you hide properties from casual inspection.

const createPrivateObject = (data) => {
  const hidden = new Set(['password', 'token', 'apiKey']);

  return new Proxy(data, {
    has(target, prop) {
      return !hidden.has(prop) && Reflect.has(target, prop);
    },
    ownKeys(target) {
      return Reflect.ownKeys(target).filter(key => !hidden.has(key));
    },
    getOwnPropertyDescriptor(target, prop) {
      if (hidden.has(prop)) {
        return undefined;
      }
      return Reflect.getOwnPropertyDescriptor(target, prop);
    }
  });
};

const user = createPrivateObject({
  username: 'alice',
  password: 'secret123',
  apiKey: 'sk-abc'
});

console.log('username' in user);    // true
console.log('password' in user);   // false
console.log(Object.keys(user));     // ["username"]

The password and API key don’t show up in enumeration or membership checks, but they still exist on the underlying object. This isn’t true security — someone with direct access to the target could read them — but it prevents accidental exposure.

Revocable proxies

ES6 provides Proxy.revocable() which returns both the proxy and a revoke function. Once you call revoke, the proxy becomes unusable and throws on any operation. This pattern is useful for access control that needs to be terminated at runtime. Once revoked, the proxy cannot intercept operations and throws a TypeError on any trap invocation.

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

console.log(proxy.secret);  // hidden-data

revoke();

try {
  console.log(proxy.secret);
} catch (err) {
  console.error('Proxy revoked');
  // TypeError: Cannot perform 'get' on a proxy: proxy has been revoked
}

This pattern is useful for access control that needs to be terminated at runtime. Once revoked, the proxy cannot intercept operations and throws a TypeError on any trap invocation.

Comparing to getters and setters

You might wonder how Proxy compares to the older getter/setter approach. Getters and setters require you to define them on the object itself, which means modifying the target. Proxy wraps the object externally, leaving the target untouched. Getters and setters also cannot intercept operations like delete, the in operator, or Object.keys(). Proxy traps cover all of those. If you need to intercept something, check which trap handles it.

Next in the series

After the proxy design pattern, the series covers the Iterator Pattern for providing a standard way to traverse collections, and the Command Pattern for encapsulating requests as objects. Both patterns complement the proxy approach by handling different aspects of object interaction.

See Also

The proxy design pattern is one piece of a larger metaprogramming toolkit in JavaScript. Once you’re comfortable with traps, look into Reflect for a unified API over object operations, and consider how Symbol can extend proxy behavior further. For more details on the underlying mechanism, see the MDN Proxy documentation.