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.

JavaScriptadvanced
17 min read

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

javascriptjavascript
// 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 validation

The Construct Trap

javascriptjavascript
// 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)); // true

Method Interception

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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
PatternTrap UsedPurposeCommon Use Case
LoggingapplyRecord function callsDebugging, auditing
ValidationapplyCheck arguments before executionAPI boundaries
MemoizationapplyCache function resultsExpensive computations
Rate limitingapplyThrottle call frequencyAPI clients
SingletonconstructEnsure single instanceService classes
Method interceptionget + applyWrap object methodsAOP, logging
Dynamic dispatchgetCreate methods on-the-flyRouting, fluent APIs
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

Can I use the apply trap on non-function objects?

No. The `apply` trap only works when the Proxy target is a callable function. If you try to use `apply` on a non-function target and then call the proxy as a function, you get a TypeError: "proxy is not a function". To intercept method calls on a regular object, use the `get` trap to return wrapped functions that intercept the call. The combination of `get` trap returning a Proxy with an `apply` trap is the standard pattern.

How do I preserve 'this' context through proxy interception?

When intercepting methods via the `get` trap, the `this` inside the original method points to the proxy, not the target. This breaks private fields and internal slots. To fix this, bind the method to the original target: `return fn.bind(target)` in the `get` trap. However, this means the method cannot see proxy-level changes. Choose based on your needs: bind to target for correct internal semantics, or pass proxy as `this` for reactive access to proxied properties.

What is the performance overhead of function proxy traps?

The `apply` trap adds roughly 2-5x overhead per function call compared to a direct call. This includes the trap invocation, Reflect.apply, and the proxy dispatch mechanism. For functions called millions of times per second in tight loops, this is significant. For API boundaries, HTTP handlers, or event callbacks called hundreds of times per second, the overhead is negligible. Profile with realistic workloads before optimizing away proxy patterns that provide valuable abstractions.

Can I intercept async function calls with the apply trap?

Yes. The `apply` trap works the same way for async functions. The original function returns a Promise, so the trap's return value is also a Promise. You can await the result inside the trap if you make the trap function `async`, or you can chain `.then()` on the result. For error handling, wrap the `Reflect.apply()` in a try-catch if you made the interceptor async, or use `.catch()` on the returned promise for non-async interceptors.

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.