The JavaScript Proxy Pattern: Complete Guide

A complete guide to the JavaScript Proxy pattern. Covers ES Proxy API, handler traps, validation proxies, access control, virtual properties, change tracking, negative indexing, and building reactive systems with Proxy and Reflect.

JavaScriptadvanced
17 min read

The Proxy pattern in JavaScript intercepts and customizes fundamental operations on objects: property access, assignment, enumeration, function invocation, and more. The ES6 Proxy API provides native support for this pattern, enabling metaprogramming capabilities like validation, logging, access control, and reactive data binding.

For the observer pattern that Proxy often powers, see JavaScript Observer Pattern: Complete Guide.

Proxy Fundamentals

A Proxy wraps an object with a handler that intercepts operations like property reads, writes, deletions, and in checks. Each interception point is called a trap. The handler below logs every operation and then forwards it to the original object using Reflect, which preserves the default behavior while giving you a hook to inject custom logic.

javascriptjavascript
const handler = {
  get(target, property, receiver) {
    console.log(`GET: ${String(property)}`);
    return Reflect.get(target, property, receiver);
  },
 
  set(target, property, value, receiver) {
    console.log(`SET: ${String(property)} = ${JSON.stringify(value)}`);
    return Reflect.set(target, property, value, receiver);
  },
 
  deleteProperty(target, property) {
    console.log(`DELETE: ${String(property)}`);
    return Reflect.deleteProperty(target, property);
  },
 
  has(target, property) {
    console.log(`HAS: ${String(property)}`);
    return Reflect.has(target, property);
  },
 
  ownKeys(target) {
    console.log("OWNKEYS");
    return Reflect.ownKeys(target);
  },
};
 
const user = new Proxy({ name: "Alice", age: 30 }, handler);
 
user.name;          // GET: name
user.email = "a@b"; // SET: email = "a@b"
"name" in user;     // HAS: name
delete user.email;  // DELETE: email
Object.keys(user);  // OWNKEYS

Validation Proxy

A validation proxy enforces data constraints at the property assignment level, so invalid data never enters the object. You define a schema with rules for type, min/max, length, regex pattern, enum values, and custom validators. The set trap runs every rule for the target property before allowing the write, and the deleteProperty trap prevents deletion of required fields.

javascriptjavascript
function createValidatedObject(target, schema) {
  return new Proxy(target, {
    set(target, property, value) {
      const rule = schema[property];
 
      if (!rule) {
        throw new Error(`Unknown property: "${String(property)}"`);
      }
 
      // Type check
      if (rule.type && typeof value !== rule.type) {
        throw new TypeError(
          `"${String(property)}" must be ${rule.type}, got ${typeof value}`
        );
      }
 
      // Range check
      if (rule.min !== undefined && value < rule.min) {
        throw new RangeError(`"${String(property)}" must be >= ${rule.min}`);
      }
      if (rule.max !== undefined && value > rule.max) {
        throw new RangeError(`"${String(property)}" must be <= ${rule.max}`);
      }
 
      // Length check
      if (rule.minLength !== undefined && value.length < rule.minLength) {
        throw new Error(`"${String(property)}" must be at least ${rule.minLength} characters`);
      }
      if (rule.maxLength !== undefined && value.length > rule.maxLength) {
        throw new Error(`"${String(property)}" must be at most ${rule.maxLength} characters`);
      }
 
      // Pattern check
      if (rule.pattern && !rule.pattern.test(value)) {
        throw new Error(`"${String(property)}" does not match required pattern`);
      }
 
      // Enum check
      if (rule.enum && !rule.enum.includes(value)) {
        throw new Error(
          `"${String(property)}" must be one of: ${rule.enum.join(", ")}`
        );
      }
 
      // Custom validator
      if (rule.validate && !rule.validate(value)) {
        throw new Error(rule.message || `"${String(property)}" validation failed`);
      }
 
      return Reflect.set(target, property, value);
    },
 
    deleteProperty(target, property) {
      const rule = schema[property];
      if (rule?.required) {
        throw new Error(`Cannot delete required property: "${String(property)}"`);
      }
      return Reflect.deleteProperty(target, property);
    },
  });
}
 
// Usage
const user = createValidatedObject(
  { name: "", email: "", age: 0, role: "viewer" },
  {
    name: { type: "string", minLength: 2, maxLength: 50, required: true },
    email: {
      type: "string",
      pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      required: true,
    },
    age: { type: "number", min: 0, max: 150 },
    role: { type: "string", enum: ["viewer", "editor", "admin"] },
  }
);
 
user.name = "Alice";            // OK
user.email = "alice@test.com";  // OK
user.age = 30;                  // OK
 
// user.name = "A";             // Error: must be at least 2 characters
// user.email = "invalid";      // Error: does not match pattern
// user.age = -5;               // RangeError: must be >= 0
// user.role = "superuser";     // Error: must be one of: viewer, editor, admin
// user.unknown = "value";      // Error: Unknown property

Access Control Proxy

This proxy gates read, write, and delete operations based on the current user's role. Each property has a permissions map listing which roles can perform each operation. The proxy exposes setRole and getRole methods so the calling code can switch roles at runtime. Any access that violates the permission rules throws an error with a clear message about which role tried which operation on which property.

javascriptjavascript
function createAccessControlled(target, permissions) {
  let currentRole = "guest";
 
  const proxy = new Proxy(target, {
    get(target, property) {
      if (property === "setRole") {
        return (role) => {
          currentRole = role;
        };
      }
 
      if (property === "getRole") {
        return () => currentRole;
      }
 
      const fieldPerms = permissions[property];
      if (fieldPerms && fieldPerms.read) {
        if (!fieldPerms.read.includes(currentRole)) {
          throw new Error(
            `Access denied: "${currentRole}" cannot read "${String(property)}"`
          );
        }
      }
 
      return Reflect.get(target, property);
    },
 
    set(target, property, value) {
      const fieldPerms = permissions[property];
      if (fieldPerms && fieldPerms.write) {
        if (!fieldPerms.write.includes(currentRole)) {
          throw new Error(
            `Access denied: "${currentRole}" cannot write "${String(property)}"`
          );
        }
      }
 
      return Reflect.set(target, property, value);
    },
 
    deleteProperty(target, property) {
      const fieldPerms = permissions[property];
      if (fieldPerms && fieldPerms.delete) {
        if (!fieldPerms.delete.includes(currentRole)) {
          throw new Error(
            `Access denied: "${currentRole}" cannot delete "${String(property)}"`
          );
        }
      }
 
      return Reflect.deleteProperty(target, property);
    },
  });
 
  return proxy;
}
 
// Usage
const document = createAccessControlled(
  { title: "Secret Report", content: "Classified data", status: "draft" },
  {
    title: { read: ["admin", "editor", "viewer"], write: ["admin", "editor"] },
    content: { read: ["admin", "editor"], write: ["admin"] },
    status: { read: ["admin", "editor", "viewer"], write: ["admin"], delete: ["admin"] },
  }
);
 
document.setRole("viewer");
console.log(document.title);     // "Secret Report"
// document.content;              // Error: "viewer" cannot read "content"
 
document.setRole("admin");
console.log(document.content);   // "Classified data"
document.content = "Updated";    // OK

Change Tracking Proxy

A change tracking proxy records every mutation as a revision entry with the property name, old value, new value, and timestamp. It also takes a full snapshot of the object after each change, which makes rollback to any previous revision possible. This pattern gives you undo/redo for free and is the same concept behind state management tools that track mutation history.

javascriptjavascript
function createTracked(target) {
  const changes = [];
  let revision = 0;
  const snapshots = [structuredClone(target)];
 
  const proxy = new Proxy(target, {
    set(target, property, value) {
      const oldValue = target[property];
      if (Object.is(oldValue, value)) return true;
 
      changes.push({
        revision: ++revision,
        property: String(property),
        oldValue: structuredClone(oldValue),
        newValue: structuredClone(value),
        timestamp: Date.now(),
      });
 
      Reflect.set(target, property, value);
      snapshots.push(structuredClone(target));
      return true;
    },
 
    deleteProperty(target, property) {
      if (property in target) {
        changes.push({
          revision: ++revision,
          property: String(property),
          oldValue: structuredClone(target[property]),
          newValue: undefined,
          timestamp: Date.now(),
          deleted: true,
        });
      }
 
      Reflect.deleteProperty(target, property);
      snapshots.push(structuredClone(target));
      return true;
    },
  });
 
  proxy.__changes = () => [...changes];
  proxy.__revision = () => revision;
  proxy.__snapshot = (rev) => structuredClone(snapshots[rev] || snapshots[0]);
  proxy.__rollback = (toRevision) => {
    const snapshot = snapshots[toRevision];
    if (!snapshot) throw new Error(`No snapshot at revision ${toRevision}`);
    for (const key of Object.keys(target)) {
      delete target[key];
    }
    Object.assign(target, structuredClone(snapshot));
    revision = toRevision;
    snapshots.length = toRevision + 1;
    changes.length = toRevision;
  };
  proxy.__isDirty = () => revision > 0;
 
  return proxy;
}
 
// Usage
const config = createTracked({ host: "localhost", port: 3000, debug: false });
 
config.port = 8080;
config.debug = true;
config.host = "0.0.0.0";
 
console.log(config.__changes());
// [
//   { revision: 1, property: "port", oldValue: 3000, newValue: 8080, ... },
//   { revision: 2, property: "debug", oldValue: false, newValue: true, ... },
//   { revision: 3, property: "host", oldValue: "localhost", newValue: "0.0.0.0", ... },
// ]
 
console.log(config.__revision()); // 3
 
// Rollback to revision 1
config.__rollback(1);
console.log(config.port);  // 8080
console.log(config.debug); // false (rolled back)

Virtual Properties and Negative Indexing

The get trap can return values for properties that do not exist on the underlying object, which lets you create virtual (computed) properties. This example builds a smart array that supports Python-style negative indexing (arr[-1] for the last element) and exposes computed properties like first, last, sum, average, unique, and compact without storing them as real data on the array.

javascriptjavascript
function createSmartArray(arr = []) {
  return new Proxy(arr, {
    get(target, property) {
      // Negative indexing: arr[-1] returns last element
      if (typeof property === "string" && /^-?\d+$/.test(property)) {
        const index = Number(property);
        if (index < 0) {
          return target[target.length + index];
        }
      }
 
      // Virtual properties
      if (property === "first") return target[0];
      if (property === "last") return target[target.length - 1];
      if (property === "isEmpty") return target.length === 0;
 
      if (property === "sum") {
        return target.reduce((a, b) => a + b, 0);
      }
 
      if (property === "average") {
        return target.length > 0
          ? target.reduce((a, b) => a + b, 0) / target.length
          : 0;
      }
 
      if (property === "unique") {
        return [...new Set(target)];
      }
 
      if (property === "compact") {
        return target.filter(Boolean);
      }
 
      return Reflect.get(target, property);
    },
 
    set(target, property, value) {
      if (typeof property === "string" && /^-?\d+$/.test(property)) {
        const index = Number(property);
        if (index < 0) {
          target[target.length + index] = value;
          return true;
        }
      }
 
      return Reflect.set(target, property, value);
    },
  });
}
 
// Usage
const nums = createSmartArray([10, 20, 30, 40, 50]);
 
console.log(nums[-1]);      // 50
console.log(nums[-2]);      // 40
console.log(nums.first);    // 10
console.log(nums.last);     // 50
console.log(nums.sum);      // 150
console.log(nums.average);  // 30
 
nums[-1] = 99;
console.log(nums.last);     // 99
 
const mixed = createSmartArray([1, null, 2, "", 3, undefined, 4]);
console.log(mixed.compact); // [1, 2, 3, 4]
console.log(mixed.unique);  // [1, null, 2, "", 3, undefined, 4]

Caching Proxy

A caching proxy intercepts method calls on an object, checks whether the same method with the same arguments has been called recently, and returns the cached result if so. It uses a TTL (time-to-live) to expire stale entries and an LRU-style eviction when the cache hits its max size. Any property write on the target clears the entire cache, since a mutation could invalidate previously cached return values.

javascriptjavascript
function createCachingProxy(target, options = {}) {
  const cache = new Map();
  const { ttl = 60000, maxSize = 100 } = options;
  let hits = 0;
  let misses = 0;
 
  return new Proxy(target, {
    get(target, property) {
      const value = Reflect.get(target, property);
 
      if (typeof value !== "function") return value;
 
      // Wrap functions with caching
      return function (...args) {
        const key = `${String(property)}:${JSON.stringify(args)}`;
 
        const cached = cache.get(key);
        if (cached && Date.now() < cached.expiresAt) {
          hits++;
          return cached.value;
        }
 
        misses++;
 
        // Evict oldest if at capacity
        if (cache.size >= maxSize) {
          const oldestKey = cache.keys().next().value;
          cache.delete(oldestKey);
        }
 
        const result = value.apply(target, args);
        cache.set(key, { value: result, expiresAt: Date.now() + ttl });
        return result;
      };
    },
 
    set(target, property, value) {
      // Invalidate cache on mutation
      cache.clear();
      return Reflect.set(target, property, value);
    },
  });
}
 
// Usage
const mathService = createCachingProxy(
  {
    fibonacci(n) {
      if (n <= 1) return n;
      let a = 0, b = 1;
      for (let i = 2; i <= n; i++) {
        [a, b] = [b, a + b];
      }
      return b;
    },
    factorial(n) {
      let result = 1;
      for (let i = 2; i <= n; i++) result *= i;
      return result;
    },
  },
  { ttl: 30000, maxSize: 50 }
);
 
console.log(mathService.fibonacci(40)); // Computed
console.log(mathService.fibonacci(40)); // Cached
console.log(mathService.factorial(20)); // Computed
Proxy TrapInterceptsCommon Use
getProperty readsVirtual props, access control, caching
setProperty writesValidation, change tracking, reactivity
deletePropertyProperty deletionProtect required fields
hasin operatorAccess control, virtual property check
ownKeysObject.keys(), for...inFilter visible properties
applyFunction callsLogging, rate limiting, memoization
constructnew operatorInstance tracking, singletons
Rune AI

Rune AI

Key Insights

  • Proxy handler traps intercept property access, assignment, deletion, and enumeration: Each trap corresponds to a fundamental object operation and can customize behavior transparently
  • Reflect provides correct default behavior inside Proxy traps: Always use Reflect methods instead of direct property access to properly handle prototype chains and receivers
  • Validation proxies enforce data constraints at the assignment level: Schema-based validation in the set trap prevents invalid data from ever entering the object
  • Change tracking proxies record every mutation with timestamps and snapshots: Full revision history enables undo/redo, audit logging, and rollback to any previous state
  • Virtual properties compute values on access without storing them: The get trap can return computed values like sum, average, first, and last that do not exist on the underlying object
RunePowered by Rune AI

Frequently Asked Questions

What is the performance cost of using Proxy?

Proxy adds roughly 5-10x overhead per property access compared to direct access in micro-benchmarks. However, for most applications this is negligible because the bottleneck is I/O, rendering, or complex computations. Avoid Proxy for hot loops that access properties millions of times per second. For validation or access control that runs only on user actions, the overhead is unnoticeable.

Can I proxy built-in objects like Array and Map?

Yes, but with caveats. Array methods like `push()` and `splice()` work through the `set` trap on numeric properties and `length`. Map and Set internal methods expect `this` to be the actual Map/Set, so you need to bind methods: `get(target, prop) { const val = target[prop]; return typeof val === "function" ? val.bind(target) : val; }`.

What is Reflect and why use it in Proxy handlers?

Reflect provides default behavior for each Proxy trap. Using `Reflect.get(target, property, receiver)` instead of `target[property]` properly handles prototype chains and getters. Reflect methods return booleans for success/failure instead of throwing, making error handling cleaner. Always use Reflect in production Proxy code.

How do I create a revocable Proxy?

Use `Proxy.revocable(target, handler)` which returns `{ proxy, revoke }`. Calling `revoke()` permanently disables the proxy. Any subsequent operations on the revoked proxy throw a TypeError. This is useful for temporary access grants, API tokens with expiry, or sandbox environments that need cleanup.

Can Proxy traps be nested?

Yes. You can return a new Proxy from a `get` trap to create deep proxies. This enables recursive validation, deep change tracking, or deep reactivity. Be careful about performance with deeply nested structures, as each property access at each level goes through its own trap.

Conclusion

The JavaScript Proxy pattern intercepts fundamental object operations to add validation, access control, caching, change tracking, and virtual properties. The Reflect API provides correct default behavior in handlers. Validation proxies enforce data constraints transparently. Change tracking proxies enable undo/redo functionality. For the observer pattern that Proxy often enables, see JavaScript Observer Pattern: Complete Guide. For the singleton pattern that uses Proxy for lazy initialization, review JavaScript Singleton Pattern: Complete Guide.