DEV Community

Samuel Ochaba
Samuel Ochaba

Posted on

JavaScript Decorators: Supercharge Your Functions Without Changing Them

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! 
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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!) 
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

We need to:

  1. Accept any number of arguments
  2. 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
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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..."
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 specific this and 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)