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.
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
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); // OWNKEYSValidation Proxy
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 propertyAccess Control Proxy
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"; // OKChange Tracking Proxy
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
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
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 Trap | Intercepts | Common Use |
|---|---|---|
| get | Property reads | Virtual props, access control, caching |
| set | Property writes | Validation, change tracking, reactivity |
| deleteProperty | Property deletion | Protect required fields |
| has | in operator | Access control, virtual property check |
| ownKeys | Object.keys(), for...in | Filter visible properties |
| apply | Function calls | Logging, rate limiting, memoization |
| construct | new operator | Instance tracking, singletons |
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
Frequently Asked Questions
What is the performance cost of using Proxy?
Can I proxy built-in objects like Array and Map?
What is Reflect and why use it in Proxy handlers?
How do I create a revocable Proxy?
Can Proxy traps be nested?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.