Intercepting Object Calls with JS Proxy Traps
Master intercepting function calls, constructor invocations, and property operations using JavaScript Proxy traps. Covers the apply and construct traps, method interception, argument validation, return value transformation, call logging, memoization, and rate limiting patterns.
JavaScript Proxy traps enable fine-grained interception of function calls, constructor invocations, and method access. The apply and construct traps intercept function-level operations, while the get trap intercepts method access for automatic wrapping and delegation patterns.
For the complete list of all 13 proxy traps, see Advanced JavaScript Proxies Complete Guide.
The Apply Trap
// The apply trap intercepts function calls: proxy(args)
// It only works when the proxy target is a function
function createInterceptedFunction(fn, interceptor) {
return new Proxy(fn, {
apply(target, thisArg, argumentsList) {
return interceptor(target, thisArg, argumentsList);
}
});
}
// LOGGING INTERCEPTOR
function withLogging(fn, label) {
return new Proxy(fn, {
apply(target, thisArg, args) {
const start = performance.now();
console.log(`[${label}] Called with:`, args);
try {
const result = Reflect.apply(target, thisArg, args);
const elapsed = (performance.now() - start).toFixed(2);
console.log(`[${label}] Returned:`, result, `(${elapsed}ms)`);
return result;
} catch (error) {
console.error(`[${label}] Threw:`, error.message);
throw error;
}
}
});
}
function multiply(a, b) {
return a * b;
}
const loggedMultiply = withLogging(multiply, "multiply");
loggedMultiply(3, 4);
// [multiply] Called with: [3, 4]
// [multiply] Returned: 12 (0.01ms)
// ARGUMENT VALIDATION
function withValidation(fn, validators) {
return new Proxy(fn, {
apply(target, thisArg, args) {
for (let i = 0; i < validators.length; i++) {
const validator = validators[i];
if (validator && !validator(args[i])) {
throw new TypeError(
`Argument ${i} failed validation: ${args[i]}`
);
}
}
return Reflect.apply(target, thisArg, args);
}
});
}
const safeDivide = withValidation(
(a, b) => a / b,
[
(v) => typeof v === "number", // a must be number
(v) => typeof v === "number" && v !== 0 // b must be non-zero number
]
);
console.log(safeDivide(10, 2)); // 5
// safeDivide(10, 0); // TypeError: Argument 1 failed validation
// safeDivide("10", 2); // TypeError: Argument 0 failed validationThe Construct Trap
// The construct trap intercepts 'new' calls: new proxy(args)
// The target must be a constructor function
function withConstructLogging(Constructor) {
return new Proxy(Constructor, {
construct(target, args, newTarget) {
console.log(`Creating ${target.name} with args:`, args);
const instance = Reflect.construct(target, args, newTarget);
console.log(`Created instance:`, instance);
return instance;
}
});
}
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
const TrackedUser = withConstructLogging(User);
const user = new TrackedUser("Alice", "alice@example.com");
// Creating User with args: ["Alice", "alice@example.com"]
// Created instance: User { name: "Alice", email: "alice@example.com" }
// SINGLETON PATTERN WITH CONSTRUCT TRAP
function singleton(Constructor) {
let instance = null;
return new Proxy(Constructor, {
construct(target, args, newTarget) {
if (!instance) {
instance = Reflect.construct(target, args, newTarget);
}
return instance;
}
});
}
const SingletonDB = singleton(class Database {
constructor(url) {
this.url = url;
this.connected = false;
console.log(`Database initialized: ${url}`);
}
connect() {
this.connected = true;
}
});
const db1 = new SingletonDB("postgres://localhost");
// "Database initialized: postgres://localhost"
const db2 = new SingletonDB("postgres://remote");
// No log (returns existing instance)
console.log(db1 === db2); // true
// INSTANCE TRACKING WITH CONSTRUCT TRAP
function withInstanceTracking(Constructor) {
const instances = new WeakSet();
let count = 0;
return new Proxy(Constructor, {
construct(target, args, newTarget) {
const instance = Reflect.construct(target, args, newTarget);
instances.add(instance);
count++;
console.log(`${target.name} instances created: ${count}`);
return instance;
},
get(target, property) {
if (property === "instanceCount") return count;
if (property === "isInstance") return (obj) => instances.has(obj);
return Reflect.get(target, property);
}
});
}
const TrackedWidget = withInstanceTracking(class Widget {
constructor(id) { this.id = id; }
});
const w1 = new TrackedWidget("header"); // Widget instances created: 1
const w2 = new TrackedWidget("footer"); // Widget instances created: 2
console.log(TrackedWidget.instanceCount); // 2
console.log(TrackedWidget.isInstance(w1)); // trueMethod Interception
// Intercept method calls on objects by wrapping them in the get trap
function withMethodLogging(obj) {
return new Proxy(obj, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
// Only wrap functions
if (typeof value === "function") {
return new Proxy(value, {
apply(fn, thisArg, args) {
console.log(`${String(property)}(${args.map(String).join(", ")})`);
const result = Reflect.apply(fn, target, args);
console.log(` -> ${JSON.stringify(result)}`);
return result;
}
});
}
return value;
}
});
}
class Calculator {
#history = [];
add(a, b) {
const result = a + b;
this.#history.push({ op: "add", a, b, result });
return result;
}
multiply(a, b) {
const result = a * b;
this.#history.push({ op: "multiply", a, b, result });
return result;
}
getHistory() {
return [...this.#history];
}
}
const calc = withMethodLogging(new Calculator());
calc.add(2, 3);
// add(2, 3)
// -> 5
calc.multiply(4, 5);
// multiply(4, 5)
// -> 20
// SELECTIVE METHOD INTERCEPTION
function interceptMethods(obj, methodNames, interceptor) {
return new Proxy(obj, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value === "function" && methodNames.includes(property)) {
return new Proxy(value, {
apply(fn, thisArg, args) {
return interceptor(property, fn, target, args);
}
});
}
return value;
}
});
}
// Only intercept specific methods
const api = interceptMethods(
{
getData() { return { items: [1, 2, 3] }; },
saveData(data) { console.log("Saved:", data); },
deleteData(id) { console.log("Deleted:", id); }
},
["saveData", "deleteData"], // Only intercept mutation methods
(method, fn, target, args) => {
console.log(`[AUDIT] ${method} called at ${new Date().toISOString()}`);
return Reflect.apply(fn, target, args);
}
);
api.getData(); // No interception
api.saveData("test"); // [AUDIT] saveData called at ...Memoization and Caching
// Automatic function memoization using the apply trap
function memoize(fn, options = {}) {
const cache = new Map();
const maxSize = options.maxSize || 1000;
const ttlMs = options.ttlMs || Infinity;
return new Proxy(fn, {
apply(target, thisArg, args) {
const key = options.keyFn
? options.keyFn(args)
: JSON.stringify(args);
const cached = cache.get(key);
if (cached) {
if (Date.now() - cached.time < ttlMs) {
return cached.value;
}
cache.delete(key);
}
const result = Reflect.apply(target, thisArg, args);
// LRU eviction: remove oldest if at capacity
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, { value: result, time: Date.now() });
return result;
}
});
}
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const fastFib = memoize(fibonacci);
// Override original to use memoized version for recursion
fibonacci = fastFib;
console.log(fastFib(40)); // Instant (memoized)
// RATE LIMITING WITH APPLY TRAP
function rateLimit(fn, maxCalls, windowMs) {
const calls = [];
return new Proxy(fn, {
apply(target, thisArg, args) {
const now = Date.now();
// Remove expired calls
while (calls.length > 0 && now - calls[0] > windowMs) {
calls.shift();
}
if (calls.length >= maxCalls) {
throw new Error(
`Rate limit exceeded: ${maxCalls} calls per ${windowMs}ms`
);
}
calls.push(now);
return Reflect.apply(target, thisArg, args);
}
});
}
const limitedFetch = rateLimit(
(url) => fetch(url),
5, // Max 5 calls
60000 // Per 60 seconds
);
// RETRY WITH APPLY TRAP
function withRetry(fn, maxRetries = 3, delayMs = 1000) {
return new Proxy(fn, {
apply(target, thisArg, args) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return Reflect.apply(target, thisArg, args);
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
console.log(`Retry ${attempt + 1}/${maxRetries} after ${delayMs}ms`);
const start = Date.now();
while (Date.now() - start < delayMs) { /* busy wait for sync */ }
}
}
}
throw lastError;
}
});
}Dynamic Method Dispatch
// Create objects with dynamic method routing using get + apply
function createRouter(routes) {
return new Proxy({}, {
get(target, property) {
// Dynamic method creation based on property name patterns
const match = String(property).match(/^(get|post|put|delete)(.+)$/);
if (match) {
const [, method, resource] = match;
const normalizedResource = resource.charAt(0).toLowerCase() + resource.slice(1);
return function (...args) {
const handler = routes[`${method}:${normalizedResource}`];
if (handler) {
return handler(...args);
}
throw new Error(`No route: ${method.toUpperCase()} /${normalizedResource}`);
};
}
return Reflect.get(target, property);
}
});
}
const api = createRouter({
"get:users": () => [{ id: 1, name: "Alice" }],
"get:user": (id) => ({ id, name: "Alice" }),
"post:user": (data) => ({ id: 2, ...data }),
"delete:user": (id) => ({ deleted: id })
});
console.log(api.getUsers()); // [{ id: 1, name: "Alice" }]
console.log(api.getUser(1)); // { id: 1, name: "Alice" }
console.log(api.postUser({ name: "Bob" })); // { id: 2, name: "Bob" }
console.log(api.deleteUser(1)); // { deleted: 1 }
// FLUENT API WITH PROXY
function createFluentProxy(target, chainableMethods) {
return new Proxy(target, {
get(obj, property, receiver) {
const value = Reflect.get(obj, property, receiver);
if (typeof value === "function" && chainableMethods.includes(property)) {
return new Proxy(value, {
apply(fn, thisArg, args) {
Reflect.apply(fn, obj, args);
return receiver; // Return proxy for chaining
}
});
}
return value;
}
});
}
class QueryBuilder {
#table = "";
#conditions = [];
#orderBy = "";
from(table) { this.#table = table; }
where(condition) { this.#conditions.push(condition); }
order(field) { this.#orderBy = field; }
build() {
let sql = `SELECT * FROM ${this.#table}`;
if (this.#conditions.length) {
sql += ` WHERE ${this.#conditions.join(" AND ")}`;
}
if (this.#orderBy) {
sql += ` ORDER BY ${this.#orderBy}`;
}
return sql;
}
}
const query = createFluentProxy(
new QueryBuilder(),
["from", "where", "order"]
);
const sql = query
.from("users")
.where("age > 18")
.where("active = true")
.order("name")
.build();
console.log(sql);
// SELECT * FROM users WHERE age > 18 AND active = true ORDER BY name| Pattern | Trap Used | Purpose | Common Use Case |
|---|---|---|---|
| Logging | apply | Record function calls | Debugging, auditing |
| Validation | apply | Check arguments before execution | API boundaries |
| Memoization | apply | Cache function results | Expensive computations |
| Rate limiting | apply | Throttle call frequency | API clients |
| Singleton | construct | Ensure single instance | Service classes |
| Method interception | get + apply | Wrap object methods | AOP, logging |
| Dynamic dispatch | get | Create methods on-the-fly | Routing, fluent APIs |
Rune AI
Key Insights
- The apply trap intercepts function calls and enables logging, validation, memoization, and rate limiting without modifying the original function: It requires the proxy target to be a callable function
- The construct trap intercepts new operator calls for singleton enforcement, instance tracking, and constructor argument validation: It must return an object; returning a primitive throws a TypeError
- Method interception combines the get trap (to wrap method access) with apply traps (to intercept the actual call): Use Reflect.apply with the original target as thisArg to preserve correct internal behavior
- Dynamic method dispatch through the get trap creates methods on-the-fly based on property name patterns: This enables fluent APIs, route-based dispatch, and magical method names
- Proxy function interception adds 2-5x overhead per call, making it suitable for API boundaries but not tight inner loops: Profile realistic workloads before deciding to remove proxy abstractions
Frequently Asked Questions
Can I use the apply trap on non-function objects?
How do I preserve 'this' context through proxy interception?
What is the performance overhead of function proxy traps?
Can I intercept async function calls with the apply trap?
Conclusion
Proxy traps provide powerful function and method interception capabilities. The apply trap handles function calls with logging, validation, memoization, and rate limiting. The construct trap intercepts constructor invocations for singletons and instance tracking. The get trap enables dynamic method dispatch and fluent APIs. For the Reflect API that complements these traps, see JavaScript Reflect API Advanced Architecture. For combining Proxy and Reflect together, explore Using Reflect and Proxy Together in JavaScript.
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.