2HAASStart a project
All posts

Code Snippets

Simplify Code with JavaScript Decorators

Discover how JavaScript decorators simplify code. Learn to apply them for logging, validation, and enhancing classes, methods, and properties effectively.

The 2HAAS team5 min read

Developers frequently encounter situations requiring repetitive code. Tasks like adding logging, validating input, or memoizing expensive computations often result in boilerplate that clutters core business logic. This not only makes code harder to read but also more challenging to maintain and scale.

What Are JavaScript Decorators?

JavaScript decorators are a special kind of declaration that can be attached to classes, methods, accessors, and properties. They provide a declarative way to add metadata or alter the behavior of these elements at design time. Essentially, a decorator is a function that takes the target (the item being decorated) and a context object, then returns a new definition or modifies the existing one.

While decorators have been a popular feature in TypeScript for years as an experimental proposal, they are now a stable part of the ECMAScript standard (Stage 3/4) and are gaining wider adoption. They offer a syntactic sugar to achieve meta-programming patterns, often simplifying common cross-cutting concerns without resorting to higher-order functions or manual wrappers.

Basic Decorator Syntax

A decorator is simply an expression that evaluates to a function, prefixed with an @ symbol. It's placed immediately before the definition of the class, method, or property it intends to decorate.

Here’s an example of a simple class decorator:

// A class decorator that logs when the class is defined and instantiated.
function logClass(target, context) {
  console.log(`[Decorator] Class '${context.name}' is being defined.`);

  // Decorators can return a new class that extends the original
  return class extends target {
    constructor(...args) {
      super(...args);
      console.log(`[Decorator] Instance of '${context.name}' created.`);
    }
  };
}

@logClass
class MyService {
  constructor(id) {
    this.id = id;
    console.log(`[MyService] Initializing MyService with ID: ${this.id}`);
  }
}

new MyService(1);
// Expected output:
// [Decorator] Class 'MyService' is being defined.
// [MyService] Initializing MyService with ID: 1
// [Decorator] Instance of 'MyService' created.

The context object provides useful information about the decorated element, such as its kind (e.g., 'class', 'method', 'field') and name.

Practical Use Cases for Decorators

Decorators shine when you need to apply the same kind of functionality across multiple, distinct parts of your codebase. Here are some common examples:

1. Logging and Debugging

You can easily add logging to methods without modifying their original implementation. This is invaluable for debugging and monitoring.

function logMethod(originalMethod, context) {
  const methodName = String(context.name);
  return function(...args) {
    console.log(`[Log] Calling method '${methodName}' with arguments:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`[Log] Method '${methodName}' returned:`, result);
    return result;
  };
}

class Calculator {
  @logMethod
  add(a, b) {
    return a + b;
  }

  @logMethod
  subtract(a, b) {
    return a - b;
  }
}

const calc = new Calculator();
calc.add(5, 3);    // Logs call and return value
calc.subtract(10, 4); // Logs call and return value

2. Input Validation

Decorators can wrap method calls to validate arguments before the original method executes, throwing errors for invalid inputs.

function validateArgs(validatorFn) {
  return function(originalMethod, context) {
    return function(...args) {
      try {
        validatorFn.apply(this, args); // Execute validation logic
      } catch (error) {
        console.error(`Validation failed for method '${String(context.name)}':`, error.message);
        throw error;
      }
      return originalMethod.apply(this, args);
    };
  };
}

class UserService {
  @validateArgs((name, email) => {
    if (typeof name !== 'string' || name.length < 3) {
      throw new Error("Name must be a string with at least 3 characters.");
    }
    if (typeof email !== 'string' || !email.includes('@')) {
      throw new Error("Email must be a valid email address.");
    }
  })
  createUser(name, email) {
    console.log(`User '${name}' created with email '${email}'.`);
    return { name, email };
  }
}

const userService = new UserService();
userService.createUser("Alice", "[email protected]");
// userService.createUser("Bo", "bob"); // This will throw an error and log it.

3. Memoization

For computationally intensive methods, decorators can cache their results to avoid redundant calculations, improving performance.

function memoize(originalMethod, context) {
  const cache = new Map();
  const methodName = String(context.name);

  return function(...args) {
    const key = JSON.stringify(args); // Simple key generation for demonstration
    if (cache.has(key)) {
      console.log(`[Memoize] Cache hit for '${methodName}' with key: ${key}`);
      return cache.get(key);
    }
    const result = originalMethod.apply(this, args);
    cache.set(key, result);
    console.log(`[Memoize] Cache miss for '${methodName}' with key: ${key}`);
    return result;
  };
}

class DataProcessor {
  @memoize
  fibonacci(n) {
    console.log(`[DataProcessor] Calculating fibonacci(${n})...`);
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }

  @memoize
  expensiveOperation(data) {
    console.log(`[DataProcessor] Performing expensive operation on: ${data}`);
    // Simulate a long-running task
    let sum = 0;
    for (let i = 0; i < 1000000; i++) {
      sum += Math.sqrt(i) * data;
    }
    return sum;
  }
}

const processor = new DataProcessor();
console.log(processor.fibonacci(10)); // Calculates
console.log(processor.fibonacci(10)); // From cache
console.log(processor.expensiveOperation(5)); // Calculates
console.log(processor.expensiveOperation(5)); // From cache

Decorator Factories (Passing Arguments)

Often, you'll want to configure your decorators with specific parameters. This is achieved by creating a "decorator factory"—a function that takes arguments and returns the actual decorator function. This pattern allows for more flexible and reusable decorators.

function deprecated(message) {
  return function(originalMethod, context) {
    const methodName = String(context.name);
    return function(...args) {
      console.warn(`[Deprecated] Method '${methodName}' is deprecated. ${message}`);
      return originalMethod.apply(this, args);
    };
  };
}

class OldSystem {
  @deprecated("This method will be removed in the next major version. Use 'computeNew' instead.")
  computeLegacy(data) {
    return data * 2;
  }

  computeNew(data) {
    return data * 3;
  }
}

const oldSys = new OldSystem();
oldSys.computeLegacy(10); // Will show a warning in the console
oldSys.computeNew(10);

Considerations and Best Practices

While decorators are powerful, they should be used judiciously:

  • Readability: Ensure decorators enhance, rather than obscure, the readability of your code. Simple, focused decorators are best.
  • Purity: Ideally, decorators should focus on modifying behavior or adding metadata without introducing complex side effects unrelated to their purpose.
  • Testing: Test your decorators thoroughly in isolation to ensure they behave as expected.
  • Overhead: While generally negligible, very complex decorators might introduce a small performance overhead due to the extra function calls. Profile if performance becomes a concern.
  • Tooling: Leverage tools like TypeScript which offer excellent support for decorators, including type checking and auto-completion.

Conclusion: Streamline Your JavaScript

JavaScript decorators provide a robust mechanism for developers to reduce boilerplate, enhance modularity, and improve the readability of their codebases. By abstracting common cross-cutting concerns into reusable units, you can keep your core logic clean and focused on its primary responsibility. Embrace decorators to write more elegant, maintainable, and efficient JavaScript applications.

Checklist for Using Decorators:

  • Identify repetitive code patterns that can be abstracted.
  • Design decorators that are single-purpose and easy to understand.
  • Use decorator factories for configurable decorator behavior.
  • Apply decorators thoughtfully, balancing power with code clarity.
  • Ensure your development environment (like TypeScript) is configured to support decorators.

Keep reading

  1. Code Snippets6 min read

    Group JavaScript Data with Object.groupBy and Map.groupBy

    Learn how to efficiently group array elements into objects or maps using the new Object.groupBy() and Map.groupBy() methods in modern JavaScript.

  2. Code Snippets4 min read

    Simplify Popups with HTML Popover API

    Learn how to effortlessly create accessible popups, tooltips, and modals using the native HTML Popover API, reducing custom JavaScript and improving user experience.

  3. Code Snippets4 min read

    Simplify Promise Handling with Promise.withResolvers

    Learn how the new Promise.withResolvers static method streamlines common asynchronous patterns in JavaScript, reducing boilerplate and improving code clarity.

Tell us what’s in the way.

A few lines is enough. You get a candid yes, a candid no, or a sharper question.

  • Working software in week four
  • A reply in one business day
  • Handover is the deliverable

Prefer email? [email protected]

The problem, who it is for, and what is in the way right now.

One business day to a real reply. No sales sequence, no call required.