Door 25 | JS Adventskalender
Skip to content

Door 25

Published: at 07:00 AMSuggest Changes

Decorators – Flexibly Extending Classes

With Decorators, JavaScript receives a powerful tool to specifically modify or enrich classes or their components without directly adapting the original code. The idea is to make metadata, logic, or changes to classes, methods, fields, or accessors by using a special function notation that is written directly above the declaration. This approach is known from other languages like TypeScript, Python, or Java and is intended to expand the JavaScript ecosystem with a declarative meta-programming function.

Important Note: Decorators in JavaScript are currently still in the stage of a TC39 Proposal (Stage 3) at the time of this article. This means they are not yet fully standardized and are only available in some environments with special flags or transpilers (e.g., Babel, TypeScript). Nevertheless, it is worth taking a look to understand how they are intended.

What are Decorators?

Decorators are functions used to modify class or member definitions. They are written directly before the class or a class element (like a method or a field) and can thus influence the definition, such as:

Simple Example: Class Decorator

A class decorator is a function that receives a class and either modifies it or returns a new class.

function sealed(constructor) {
  // Example decorator that prevents extending the class after definition
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed
class Example {
  constructor(value) {
    this.value = value;
  }
}

const ex = new Example(10);
console.log(ex.value); // 10

// Attempt to modify the class afterwards fails
try {
  Example.prototype.newProp = 'test';
} catch (err) {
  console.error('Class is sealed:', err.message);
}

Here, the Example class is modified by the @sealed decorator. The decorator ensures that neither the class nor its prototype can be changed after declaration.

Decorating Methods

Often you want to provide individual methods with additional logic, such as logging calls or checking access rights. Decorators for methods receive information about the target object, the name of the method, and its original descriptors and can customize them.

function logCall(_, context) {
  const originalMethod = context.descriptor.value;
  context.descriptor.value = function(...args) {
    console.log(`Calling ${String(context.name)} with arguments:`, args);
    return originalMethod.apply(this, args);
  };
}

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

const calc = new Calculator();
console.log(calc.add(3, 7)); 
// Output:
// Calling add with arguments: [ 3, 7 ]
// 10

In this example, logging is automatically executed when calling add before the original logic of the method is called. The decorator simply replaces the method with a new function that performs additional tasks.

Decorating Fields and Accessors

With Decorators, you can also decorate properties (fields) or accessors (getters, setters). This is useful for enforcing default values, performing validations, or manipulating private fields (if the decorator has access).

function readOnly(_, context) {
  if (context.kind === 'field') {
    return function (initialValue) {
      // Do not allow overwriting this field after initial setting
      Object.defineProperty(this, context.name, {
        value: initialValue,
        writable: false,
        enumerable: true,
        configurable: false
      });
    }
  }
}

class User {
  @readOnly
  id = Math.floor(Math.random() * 1000);

  constructor(name) {
    this.name = name;
  }
}

const u = new User('Anna');
console.log(u.id); // e.g., 123
u.id = 999; // No effect, since readOnly field
console.log(u.id); // still 123

The field decorator @readOnly ensures here that the field id is only set once and cannot be changed afterwards.

Practical Scenarios for Decorators

Below you will find a practical code example for each of the mentioned use cases of Decorators. Please note that Decorators are currently still in the proposal phase (as of ES) and may require a transpiler like Babel or TypeScript to be used in real code.

1. Logging and Debugging:

function logExecution(_, context) {
  const originalMethod = context.descriptor.value;
  context.descriptor.value = function(...args) {
    const start = performance.now();
    console.log(`[LOG] Method "${String(context.name)}" called at: ${new Date().toISOString()}`);
    const result = originalMethod.apply(this, args);
    const end = performance.now();
    console.log(`[LOG] Execution of "${String(context.name)}" took: ${end - start} ms`);
    return result;
  };
}

class DataService {
  @logExecution
  fetchData() {
    for (let i = 0; i < 1_000_000; i++) {}
    return { data: 'Example' };
  }
}

2. Validation and Security:

function requireRole(requiredRole) {
  return function(_, context) {
    const originalMethod = context.descriptor.value;
    context.descriptor.value = function(user, ...args) {
      if (!user || user.role !== requiredRole) {
        throw new Error(`Access denied: User does not have required role "${requiredRole}".`);
      }

      if (typeof args[0] !== 'number') {
        throw new TypeError('First argument must be a number.');
      }

      return originalMethod.apply(this, [user, ...args]);
    };
  };
}

class SecureService {
  @requireRole('admin')
  deleteRecord(user, recordId) {
    console.log(`Record ${recordId} is being deleted by ${user.name}.`);
  }
}

3. Caching and Memoization:

function memoize(_, context) {
  const originalMethod = context.descriptor.value;
  const cache = new Map();

  context.descriptor.value = function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      console.log(`Cache hit for arguments: ${key}`);
      return cache.get(key);
    }
    const result = originalMethod.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

class MathService {
  @memoize
  expensiveCalculation(x, y) {
    for (let i = 0; i < 1_000_000; i++) {}
    return x * y;
  }
}

4. Configuration and Metadata:

function route(config) {
  return function(_, context) {
    if (!context.metadata) {
      context.metadata = {};
    }
    context.metadata.route = config;
  };
}

class ApiController {
  @route({ path: '/users', method: 'GET' })
  getUsers() {
    return [{ id: 1, name: 'Anna' }, { id: 2, name: 'Ben' }];
  }

  @route({ path: '/users', method: 'POST' })
  createUser(data) {
    return { success: true };
  }
}

Current Status

The Decorator syntax is not yet finalized. Currently, there are different proposals and implementations, for example in TypeScript and Babel. In TypeScript, decorators have been usable for a long time (with experimentalDecorators), so you can get a taste of what the final standard might look like. However, it is possible that the syntax may change slightly.

Conclusion

Decorators make classes and their components more modular, adaptable, and declarative. Instead of hard-coding logic into each method, for example, you can elegantly extend it through a decorator. This promotes clean, reusable code and is a big plus especially in larger projects or frameworks. Even though Decorators are not yet natively supported by all JavaScript engines, it is already worth getting to know the concept and being excited about upcoming language versions.

Experiment with Decorators in your next project and discover how they can add new levels of flexibility to your code!


Next Post
Door 24