How to Use Decorators, Forwarding, call, and apply in JavaScript
A decorator is a function that takes another function and extends or alters its behavior without modifying the original. This is one of the most powerful patterns in JavaScript. Decorators allow you to add caching, logging, access control, rate limiting, and many other cross-cutting concerns to any function, cleanly and reusably.
To build robust decorators, you need to understand two fundamental methods that every function has: call and apply. These methods let you invoke a function with an explicitly specified this context and arguments, which is essential for forwarding calls transparently. This guide takes you from a simple caching decorator through call and apply, method borrowing, and into the essential real-world decorators: debounce and throttle.
Transparent Caching Decorator
Let's start with a practical problem. You have a function that performs an expensive computation, and you want to cache its results so that repeated calls with the same arguments return instantly.
The Problem: Repeated Expensive Calls
function slowSquare(x) {
// Simulate heavy computation
console.log(`Computing square of ${x}...`);
let result = 0;
for (let i = 0; i < 1_000_000; i++) {
result = x * x;
}
return result;
}
console.log(slowSquare(5)); // Computing square of 5... → 25
console.log(slowSquare(5)); // Computing square of 5... → 25 (computed again!)
console.log(slowSquare(5)); // Computing square of 5... → 25 (and again!)
Every call recomputes the result, even for the same input.
The Solution: A Caching Decorator
Instead of modifying slowSquare, we create a wrapper function that adds caching:
function cachingDecorator(func) {
const cache = new Map();
return function(x) {
if (cache.has(x)) {
console.log(`Cache hit for ${x}`);
return cache.get(x);
}
const result = func(x);
cache.set(x, result);
return result;
};
}
const fastSquare = cachingDecorator(slowSquare);
console.log(fastSquare(5)); // Computing square of 5... → 25
console.log(fastSquare(5)); // Cache hit for 5 → 25 (instant!)
console.log(fastSquare(5)); // Cache hit for 5 → 25 (instant!)
console.log(fastSquare(7)); // Computing square of 7... → 49
console.log(fastSquare(7)); // Cache hit for 7 → 49
The decorator wraps the original function, storing results in a Map. On subsequent calls with the same argument, it returns the cached result without calling the original function.
Why This Approach Is Powerful
- The original function is not modified. It remains pure and reusable.
- The caching logic is reusable. You can apply it to any single-argument function.
- The concerns are separated. The function handles computation; the decorator handles caching.
function slowCube(x) {
console.log(`Computing cube of ${x}...`);
return x ** 3;
}
const fastCube = cachingDecorator(slowCube);
console.log(fastCube(3)); // Computing cube of 3... → 27
console.log(fastCube(3)); // Cache hit for 3 → 27
The Problem: What About Object Methods?
The simple caching decorator breaks when used with object methods, because this is lost:
const worker = {
multiplier: 2,
slow(x) {
console.log(`Computing ${x} * ${this.multiplier}...`);
return x * this.multiplier;
}
};
console.log(worker.slow(5)); // Computing 5 * 2... → 10
worker.slow = cachingDecorator(worker.slow);
console.log(worker.slow(5));
// Computing 5 * undefined...
// → NaN!
When the wrapper calls func(x), it calls func as a standalone function, not as a method of worker. So this inside slow becomes undefined (strict mode) or window (non-strict), not worker. To fix this, we need func.call.
func.call(context, ...args): Calling with Explicit this
The call method exists on every function. It lets you invoke a function while explicitly setting the value of this.
Syntax
func.call(thisArg, arg1, arg2, ...);
thisArg: The value to use asthiswhen callingfuncarg1, arg2, ...: Arguments passed tofuncindividually
Basic Examples
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const alice = { name: "Alice" };
const bob = { name: "Bob" };
greet.call(alice, "Hello", "!"); // "Hello, Alice!"
greet.call(bob, "Hey", "."); // "Hey, Bob."
Without call, the function would have no idea what this should be. With call, you explicitly tell it.
Using call in Decorators
Now we can fix the caching decorator to preserve this:
function cachingDecorator(func) {
const cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
// Use func.call(this, x) instead of func(x)
// "this" here is whatever the wrapper was called on
const result = func.call(this, x);
cache.set(x, result);
return result;
};
}
const worker = {
multiplier: 2,
slow(x) {
console.log(`Computing ${x} * ${this.multiplier}...`);
return x * this.multiplier;
}
};
worker.slow = cachingDecorator(worker.slow);
console.log(worker.slow(5)); // Computing 5 * 2... → 10
console.log(worker.slow(5)); // 10 (from cache)
Here is what happens step by step:
worker.slow(5)calls the wrapper function. Inside the wrapper,thisisworker(because the wrapper is called asworker.slow(5)).- The wrapper calls
func.call(this, x), which isfunc.call(worker, 5). - The original
slowfunction runs withthis === worker, sothis.multiplieris2.
Handling Multiple Arguments
The caching decorator above only works with single-argument functions. For multi-argument functions, you need to generate a cache key from all arguments:
function cachingDecorator(func, hashFn) {
const cache = new Map();
return function() {
// Generate cache key from all arguments
const key = hashFn
? hashFn.call(this, ...arguments)
: Array.from(arguments).join(",");
if (cache.has(key)) {
return cache.get(key);
}
const result = func.call(this, ...arguments);
cache.set(key, result);
return result;
};
}
function slowMultiply(a, b) {
console.log(`Computing ${a} * ${b}...`);
return a * b;
}
const fastMultiply = cachingDecorator(slowMultiply);
console.log(fastMultiply(3, 4)); // Computing 3 * 4... → 12
console.log(fastMultiply(3, 4)); // 12 (from cache, key: "3,4")
console.log(fastMultiply(4, 3)); // Computing 4 * 3... → 12 (different key: "4,3")
func.apply(context, args): Call with Array of Arguments
apply is almost identical to call, but it accepts arguments as an array (or array-like object) instead of individually.
Syntax
func.apply(thisArg, [arg1, arg2, ...]);
call vs. apply
function introduce(greeting, farewell) {
console.log(`${greeting}, I'm ${this.name}. ${farewell}!`);
}
const user = { name: "Alice" };
// call: arguments passed individually
introduce.call(user, "Hello", "Goodbye");
// "Hello, I'm Alice. Goodbye!"
// apply: arguments passed as an array
introduce.apply(user, ["Hello", "Goodbye"]);
// "Hello, I'm Alice. Goodbye!"
Both produce identical results. The difference is purely syntactic.
When to Use apply vs. call
The traditional rule was:
- Use
callwhen you know the arguments individually - Use
applywhen you have the arguments in an array
// With call (you know each argument)
func.call(context, arg1, arg2, arg3);
// With apply (you have an array of arguments)
const args = [arg1, arg2, arg3];
func.apply(context, args);
However, with modern spread syntax, this distinction is largely irrelevant:
// Modern approach: call with spread (works like apply)
const args = [arg1, arg2, arg3];
func.call(context, ...args);
apply with Math.max
A classic use of apply is passing an array to functions that expect individual arguments:
const numbers = [5, 2, 8, 1, 9, 3];
// Math.max expects individual arguments, not an array
// Math.max([5, 2, 8, 1, 9, 3]) → NaN
// Using apply:
console.log(Math.max.apply(null, numbers)); // 9
// Modern alternative with spread:
console.log(Math.max(...numbers)); // 9
Using apply in Decorators
apply is particularly useful in decorators because you can forward all arguments without knowing how many there are:
function loggingDecorator(func) {
return function() {
console.log(`Calling ${func.name} with args:`, Array.from(arguments));
const result = func.apply(this, arguments);
console.log(`${func.name} returned:`, result);
return result;
};
}
function add(a, b) {
return a + b;
}
const loggedAdd = loggingDecorator(add);
loggedAdd(3, 4);
// Calling add with args: [3, 4]
// add returned: 7
Modern Decorator with Rest/Spread (Preferred)
In modern JavaScript, you can use rest parameters and spread syntax instead of arguments and apply:
function loggingDecorator(func) {
return function(...args) {
console.log(`Calling ${func.name} with args:`, args);
const result = func.call(this, ...args);
console.log(`${func.name} returned:`, result);
return result;
};
}
This is cleaner because args is a real array, and spread syntax is more readable than apply.
call vs. apply Quick Reference
| Feature | call | apply |
|---|---|---|
| Arguments | Passed individually | Passed as an array |
| Syntax | func.call(ctx, a, b, c) | func.apply(ctx, [a, b, c]) |
| Performance | Slightly faster in some engines | Slightly slower (array processing) |
| Modern alternative | func.call(ctx, ...args) | Same (spread makes them equivalent) |
| Mnemonic | Call = Commas | Apply = Array |
Borrowing Methods
Sometimes you want to use a method from one object on a different object. This technique is called method borrowing and relies on call or apply.
Borrowing Array Methods for Array-Like Objects
The most common example is borrowing array methods for use with array-like objects (objects with numeric indices and a length property but no array methods):
function showArguments() {
// "arguments" is array-like but NOT an array
console.log(arguments); // Arguments(3) [1, 2, 3]
console.log(arguments.length); // 3
// WRONG: arguments does not have array methods
// arguments.join(", "); // TypeError: arguments.join is not a function
// Borrow "join" from Array.prototype
const result = Array.prototype.join.call(arguments, ", ");
console.log(result); // "1, 2, 3"
}
showArguments(1, 2, 3);
How Method Borrowing Works
When you call Array.prototype.join.call(arguments, ", "):
- JavaScript looks up the
joinmethod onArray.prototype - It calls
joinwiththisset toarguments(viacall) joinonly needsthisto have numeric indices and alengthpropertyargumentshas both, so it works perfectly
The join method does not care what this actually is. It only cares that this is "array-like." This is duck typing in action.
More Borrowing Examples
// Borrowing Array.prototype.slice to convert array-likes to arrays
function toArray() {
return Array.prototype.slice.call(arguments);
}
console.log(toArray(1, 2, 3)); // [1, 2, 3] (a real array!)
// Borrowing Array.prototype.forEach for a NodeList
const divs = document.querySelectorAll("div"); // NodeList, not an Array
Array.prototype.forEach.call(divs, function(div) {
div.style.color = "red";
});
// Borrowing Array.prototype.filter
function filterArguments(predicate) {
return Array.prototype.filter.call(arguments, predicate);
}
Borrowing toString for Type Checking
A powerful method borrowing technique is using Object.prototype.toString for reliable type checking:
const toString = Object.prototype.toString;
console.log(toString.call(123)); // "[object Number]"
console.log(toString.call("hello")); // "[object String]"
console.log(toString.call(true)); // "[object Boolean]"
console.log(toString.call(null)); // "[object Null]"
console.log(toString.call(undefined)); // "[object Undefined]"
console.log(toString.call([])); // "[object Array]"
console.log(toString.call({})); // "[object Object]"
console.log(toString.call(new Map())); // "[object Map]"
console.log(toString.call(/regex/)); // "[object RegExp]"
console.log(toString.call(new Date())); // "[object Date]"
This works because Object.prototype.toString inspects the internal [[Class]] of the value, providing a reliable type identification that typeof cannot match.
In modern JavaScript, you can often avoid method borrowing by converting array-likes to real arrays using Array.from() or spread syntax ([...arrayLike]). However, understanding method borrowing is still important for reading older code and for situations where creating a new array would be wasteful.
Modern Alternatives to Method Borrowing
// Instead of Array.prototype.slice.call(arguments):
function modern(...args) {
console.log(args); // Already a real array!
}
// Instead of Array.prototype.forEach.call(nodeList, fn):
const divs = document.querySelectorAll("div");
[...divs].forEach(div => div.style.color = "red");
// or
Array.from(divs).forEach(div => div.style.color = "red");
// or
divs.forEach(div => div.style.color = "red"); // NodeList has forEach in modern browsers
Writing Robust Decorators (Preserving this and Properties)
A well-written decorator should be transparent: the wrapped function should behave exactly like the original from the caller's perspective. This means preserving this, forwarding all arguments, returning the correct value, and ideally preserving function properties.
The Decorator Template
Here is a robust template for decorators:
function decorator(func) {
return function wrapper(...args) {
// "this" is whatever the wrapper was called on
// "args" contains all arguments as a real array
// --- Do something before ---
// Forward the call with the correct "this" and all arguments
const result = func.call(this, ...args);
// --- Do something after ---
return result;
};
}
Complete Logging Decorator
function loggingDecorator(func) {
function wrapper(...args) {
const start = performance.now();
console.log(`→ ${func.name}(${args.map(a => JSON.stringify(a)).join(", ")})`);
const result = func.call(this, ...args);
const duration = (performance.now() - start).toFixed(2);
console.log(`← ${func.name} returned ${JSON.stringify(result)} [${duration}ms]`);
return result;
}
return wrapper;
}
function add(a, b) {
return a + b;
}
const loggedAdd = loggingDecorator(add);
loggedAdd(3, 4);
// → add(3, 4)
// ← add returned 7 [0.01ms]
Preserving Function Properties
When you wrap a function in a decorator, the original function's custom properties, name, and length are lost because the wrapper is a different function:
function original(a, b, c) {
return a + b + c;
}
original.version = "1.0";
const wrapped = loggingDecorator(original);
console.log(wrapped.name); // "wrapper" (not "original"!)
console.log(wrapped.length); // 0 (not 3!, rest params have length 0)
console.log(wrapped.version); // undefined (custom property lost!)
To fix this, you can copy properties from the original function to the wrapper:
function robustDecorator(func) {
function wrapper(...args) {
return func.call(this, ...args);
}
// Copy all own properties from func to wrapper
Object.keys(func).forEach(key => {
wrapper[key] = func[key];
});
// Fix the name property
Object.defineProperty(wrapper, "name", {
value: func.name,
configurable: true
});
// Fix the length property
Object.defineProperty(wrapper, "length", {
value: func.length,
configurable: true
});
return wrapper;
}
const wrapped = robustDecorator(original);
console.log(wrapped.name); // "original"
console.log(wrapped.length); // 3
console.log(wrapped.version); // "1.0"
Decorator Composition
Multiple decorators can be stacked on the same function:
function timingDecorator(func) {
return function(...args) {
const start = performance.now();
const result = func.call(this, ...args);
const end = performance.now();
console.log(`${func.name} took ${(end - start).toFixed(2)}ms`);
return result;
};
}
function validationDecorator(func) {
return function(...args) {
for (const arg of args) {
if (typeof arg !== "number") {
throw new TypeError(`Expected number, got ${typeof arg}: ${arg}`);
}
}
return func.call(this, ...args);
};
}
function add(a, b) {
return a + b;
}
// Stack decorators: validation runs first, then timing, then the original function
const safeTimedAdd = timingDecorator(validationDecorator(add));
console.log(safeTimedAdd(3, 4)); // add took 0.01ms → 7
// safeTimedAdd(3, "four"); // TypeError: Expected number, got string: four
Access Control Decorator
function requireAuth(func) {
return function(...args) {
if (!this.isAuthenticated) {
throw new Error("Authentication required");
}
return func.call(this, ...args);
};
}
const api = {
isAuthenticated: false,
getData() {
return { secret: "classified information" };
}
};
api.getData = requireAuth(api.getData);
try {
api.getData(); // Error: Authentication required
} catch (e) {
console.log(e.message);
}
api.isAuthenticated = true;
console.log(api.getData()); // { secret: "classified information" }
Debouncing and Throttling: Essential Decorators
Debounce and throttle are two of the most commonly used decorators in front-end development. They control how often a function can be called, which is critical for performance when handling rapid events like scrolling, typing, or window resizing.
Debounce: Wait Until the Storm Passes
A debounced function delays execution until a specified period of inactivity. If the function is called again during the waiting period, the timer resets.
Think of an elevator door: it waits for a pause in people entering before closing. Every new person resets the timer.
function debounce(func, delay) {
let timeoutId;
return function(...args) {
// Clear any existing timer
clearTimeout(timeoutId);
// Set a new timer
timeoutId = setTimeout(() => {
func.call(this, ...args);
}, delay);
};
}
Use case: Search-as-you-type
Without debounce, every keystroke would trigger an API call:
// WITHOUT debounce (fires on EVERY keystroke)
searchInput.addEventListener("input", function() {
fetch(`/api/search?q=${this.value}`); // 20 keystrokes = 20 API calls!
});
// WITH debounce (fires only after the user stops typing for 300ms)
const debouncedSearch = debounce(function() {
fetch(`/api/search?q=${this.value}`);
console.log(`Searching for: ${this.value}`);
}, 300);
searchInput.addEventListener("input", debouncedSearch);
// User types "javascript" quickly:
// Only ONE API call is made, 300ms after the last keystroke
Visualizing debounce:
Events: x x x x x · · · x x · · ·
Timer: ←→ ←→ ←→ ←→ ←--300ms--→ ←--300ms--→
Execution: ✓ (fire!) ✓ (fire!)
Each "x" resets the timer. The function only fires after a gap of 300ms.
Use case: Window resize handler
const handleResize = debounce(function() {
console.log(`Window size: ${window.innerWidth}x${window.innerHeight}`);
recalculateLayout();
}, 250);
window.addEventListener("resize", handleResize);
// Fires only once, 250ms after the user stops resizing
Throttle: At Most Once Per Period
A throttled function executes at most once every specified time period, regardless of how many times it is called. It guarantees regular execution during continuous events.
Think of a machine gun with a fire rate limit: no matter how fast you pull the trigger, it fires at most once per fixed interval.
function throttle(func, limit) {
let inThrottle = false;
return function(...args) {
if (!inThrottle) {
func.call(this, ...args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
Use case: Scroll event handler
Scroll events can fire dozens of times per second. Throttling limits the handler to a manageable rate:
const handleScroll = throttle(function() {
const scrollPercentage = Math.round(
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
);
console.log(`Scrolled: ${scrollPercentage}%`);
updateProgressBar(scrollPercentage);
}, 200);
window.addEventListener("scroll", handleScroll);
// Fires at most once every 200ms during scrolling
Visualizing throttle:
Events: x x x x x x x x x x x
Execution: ✓ · · · ✓ · · · ✓ · ·
|←--200ms--→|←--200ms--→|←--200ms--→|
The function fires immediately, then ignores calls for 200ms, then fires again.
Enhanced Throttle with Trailing Call
The simple throttle above drops all intermediate calls. A more robust version ensures the last call during the throttled period is eventually executed:
function throttle(func, limit) {
let lastCall = 0;
let timeoutId = null;
return function(...args) {
const now = Date.now();
const remaining = limit - (now - lastCall);
if (remaining <= 0) {
// Enough time has passed (execute immediately)
clearTimeout(timeoutId);
timeoutId = null;
lastCall = now;
func.call(this, ...args);
} else if (!timeoutId) {
// Schedule a trailing call
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
func.call(this, ...args);
}, remaining);
}
};
}
Debounce vs. Throttle Comparison
| Feature | Debounce | Throttle |
|---|---|---|
| When it fires | After a pause in calls | At regular intervals during calls |
| Frequency | Once, after activity stops | At most once per time period |
| Best for | Search input, form validation, resize end | Scroll handlers, mouse move, rate limiting |
| Guarantees | Runs after the final call + delay | Runs at regular intervals during activity |
Visual comparison:
Raw events: x x x x x x x · · · x x x x · · ·
Debounce(300): · · · · · · · · · ✓ · · · · · · ✓
↑ ↑
after pause after pause
Throttle(300): ✓ · · ✓ · · ✓ · · · ✓ · · ✓ · · ·
↑ ↑ ↑ ↑ ↑
regular intervals regular intervals
When to Use Which
// DEBOUNCE: "Wait until the user is done"
// - Search-as-you-type API calls
// - Form validation after user finishes typing
// - Save draft after user stops editing
// - Resize calculations after resize ends
// THROTTLE: "Do this regularly while it's happening"
// - Scroll position tracking
// - Mouse position logging (e.g., drawing apps)
// - Analytics event sending
// - Rate-limiting API requests
// - Game loop actions (movement, shooting)
Production-Ready Debounce with Cancel and Immediate
function debounce(func, delay, { leading = false } = {}) {
let timeoutId;
let lastArgs;
let lastThis;
function debounced(...args) {
lastArgs = args;
lastThis = this;
clearTimeout(timeoutId);
if (leading && !timeoutId) {
func.call(this, ...args);
}
timeoutId = setTimeout(() => {
if (!leading) {
func.call(lastThis, ...lastArgs);
}
timeoutId = null;
}, delay);
}
debounced.cancel = function() {
clearTimeout(timeoutId);
timeoutId = null;
};
return debounced;
}
// Usage with cancel
const debouncedSave = debounce(saveToServer, 1000);
input.addEventListener("input", debouncedSave);
// Cancel if the user navigates away
window.addEventListener("beforeunload", () => {
debouncedSave.cancel();
});
// Usage with leading: fires immediately on FIRST call, then debounces
const debouncedClick = debounce(handleClick, 500, { leading: true });
button.addEventListener("click", debouncedClick);
// First click fires immediately, rapid subsequent clicks are ignored
Summary
| Concept | Key Takeaway |
|---|---|
| Decorator | A function that wraps another function to extend its behavior without modifying it |
func.call(ctx, a, b) | Calls func with this set to ctx and arguments passed individually |
func.apply(ctx, [a, b]) | Calls func with this set to ctx and arguments passed as an array |
call vs apply | Identical except for how arguments are passed; spread makes them interchangeable |
| Method borrowing | Using call/apply to run one object's method on a different object |
| Transparent forwarding | Using func.call(this, ...args) to forward both this and all arguments |
| Debounce | Delays execution until a pause in calls; ideal for search input, form validation |
| Throttle | Limits execution to at most once per time period; ideal for scroll, resize, mouse events |
Decorators are a cornerstone of clean, maintainable JavaScript. They let you add behavior like caching, logging, validation, timing, debouncing, and throttling as reusable, composable layers. The key to writing good decorators is transparent forwarding with func.call(this, ...args), ensuring the wrapper behaves exactly like the original from the caller's perspective.