Ever wanted to add extra features to a function without actually modifying its code? That's exactly what decorators do, and they're way cooler than they sound. Let's dive in!
What's a Decorator Anyway?
Think of a decorator like gift wrapping. The gift (your function) stays the same, but you're adding a beautiful layer around it that makes it special.
Here's a real problem: you have a function that's really slow, and it gets called multiple times with the same inputs.
function calculateComplexStuff(num) {
console.log(`Crunching numbers for ${num}...`);
// Imagine this takes 3 seconds
return num * 2;
}
calculateComplexStuff(5); // Takes 3 seconds
calculateComplexStuff(5); // Takes another 3 seconds (same calculation!)
calculateComplexStuff(5); // Yet another 3 seconds!
Wouldn't it be great if we could remember the results?
Your First Decorator: Adding a Cache
Let's create a decorator that adds caching to any function:
function addCache(func) {
let cache = new Map();
return function(x) {
// Check if we've seen this input before
if (cache.has(x)) {
console.log(`Found in cache!`);
return cache.get(x);
}
// First time seeing this input
let result = func(x);
cache.set(x, result);
return result;
};
}
// Wrap our slow function
let fastCalculate = addCache(calculateComplexStuff);
fastCalculate(5); // "Crunching numbers..." (slow)
fastCalculate(5); // "Found in cache!" (instant! ⚡)
fastCalculate(5); // "Found in cache!" (instant!)
Boom! You just made your function way faster without touching the original code.
Why Decorators Are Awesome
1. Reusability - Write the decorator once, use it everywhere:
function fetchUserData(userId) {
// Expensive API call
return fetch(`/api/users/${userId}`);
}
function fetchProductData(productId) {
// Another expensive API call
return fetch(`/api/products/${productId}`);
}
// Cache both functions!
fetchUserData = addCache(fetchUserData);
fetchProductData = addCache(fetchProductData);
2. Keep your code clean - The original functions stay simple and focused.
3. Mix and match - You can stack multiple decorators together!
The this Problem Strikes Again
Here's where things get tricky. What if your function is a method inside an object?
let calculator = {
multiplier: 10,
calculate(num) {
console.log(`Calculating ${num}...`);
return num * this.multiplier; // Using 'this'!
}
};
calculator.calculate(5); // 50
// Try to cache it
calculator.calculate = addCache(calculator.calculate);
calculator.calculate(5); // Error! 'this' is undefined
The problem? Our decorator doesn't pass along this when it calls the original function.
The Fix: Using call()
JavaScript has a built-in method called call() that lets you specify what this should be:
function addCache(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
// Pass 'this' along!
let result = func.call(this, x);
cache.set(x, result);
return result;
};
}
let calculator = {
multiplier: 10,
calculate(num) {
return num * this.multiplier;
}
};
calculator.calculate = addCache(calculator.calculate);
calculator.calculate(5); // 50
calculator.calculate(5); // 50 (from cache!)
Quick call() Explainer
function greet(greeting) {
console.log(`${greeting}, I'm ${this.name}`);
}
let person1 = { name: "Alice" };
let person2 = { name: "Bob" };
greet.call(person1, "Hello"); // "Hello, I'm Alice"
greet.call(person2, "Hi"); // "Hi, I'm Bob"
call() lets you say: "Run this function, but pretend this is this object."
Handling Multiple Arguments
Our decorator only works with one argument. What if we need more?
let calculator = {
add(a, b, c) {
console.log(`Adding ${a}, ${b}, ${c}`);
return a + b + c;
}
};
We need to:
- Accept any number of arguments
- Create a unique cache key for each combination
function addCache(func, makeKey) {
let cache = new Map();
return function(...args) { // Accept any number of arguments
let key = makeKey(args);
if (cache.has(key)) {
return cache.get(key);
}
let result = func.call(this, ...args); // Pass all args
cache.set(key, result);
return result;
};
}
// Helper function to make cache keys
function makeKey(args) {
return args.join(',');
}
calculator.add = addCache(calculator.add, makeKey);
calculator.add(1, 2, 3); // "Adding 1, 2, 3" → 6
calculator.add(1, 2, 3); // From cache! → 6
calculator.add(5, 5, 5); // "Adding 5, 5, 5" → 15
The Power of apply()
There's another method similar to call() called apply():
// These do the same thing:
func.call(context, arg1, arg2, arg3);
func.apply(context, [arg1, arg2, arg3]);
The difference? call() takes arguments one by one, while apply() takes them as an array.
Here's a practical example with a logger decorator:
function addLogging(func) {
return function(...args) {
console.log(`Calling with: ${args}`);
let result = func.apply(this, args);
console.log(`Result: ${result}`);
return result;
};
}
function add(a, b) {
return a + b;
}
let loggedAdd = addLogging(add);
loggedAdd(3, 4);
// Logs: "Calling with: 3,4"
// Logs: "Result: 7"
// Returns: 7
Real-World Example: API Rate Limiter
Let's build something practical - a decorator that prevents calling an API too frequently:
function rateLimiter(func, delay) {
let lastCall = 0;
let pending = null;
return function(...args) {
let now = Date.now();
let timeSinceLastCall = now - lastCall;
if (timeSinceLastCall >= delay) {
lastCall = now;
return func.apply(this, args);
} else {
console.log('⏰ Rate limited! Please wait...');
// Clear any existing timeout
if (pending) clearTimeout(pending);
// Schedule for later
return new Promise(resolve => {
pending = setTimeout(() => {
lastCall = Date.now();
resolve(func.apply(this, args));
}, delay - timeSinceLastCall);
});
}
};
}
// Only allow API calls every 2 seconds
let fetchData = rateLimiter(function(endpoint) {
console.log(`🌐 Fetching from ${endpoint}`);
return fetch(endpoint);
}, 2000);
fetchData('/api/data'); // Executes immediately
fetchData('/api/data'); // "Rate limited! Please wait..."
Stacking Decorators
You can combine multiple decorators! Here's a function with both caching AND logging:
function multiply(a, b) {
return a * b;
}
multiply = addLogging(multiply);
multiply = addCache(multiply, args => args.join(','));
multiply(5, 3); // Logs "Calling..." then caches result
multiply(5, 3); // Returns from cache (logs this too!)
Pro Tip: Method Borrowing
Sometimes you want to use a method from one object on another object. Here's a cool trick:
// We want to join arguments, but arguments isn't a real array
function showArgs() {
// This won't work: arguments.join(',')
// But this will!
let result = [].join.call(arguments, ' + ');
console.log(result);
}
showArgs(1, 2, 3); // "1 + 2 + 3"
We "borrowed" the join method from an empty array and used it on arguments!
Quick Reference
When to use decorators:
- Adding caching to expensive functions
- Logging function calls for debugging
- Rate limiting API calls
- Adding authentication checks
- Measuring performance
Key concepts:
-
func.call(context, arg1, arg2)- Call with specificthisand arguments -
func.apply(context, [args])- Same, but args as array -
...args- Collect all arguments - Decorators return wrapped versions of functions
Wrapping Up
Decorators are like superpowers for your functions:
- Add features without changing original code
- Keep your functions simple and focused
- Reuse the same decorator everywhere
- Stack multiple decorators together
Next time you find yourself copying the same pattern around different functions, think: "Could I make a decorator for this?"
Happy coding!
Top comments (0)