JavaScript Decorators: Decorating Classes and Methods
AI generated
JS
() =>
JavaScript · Decorators · TC39 · Metaprogramming
JavaScript Decorators
Transforming Classes and Methods with @decorator

After years of wrangling in the TC39 committee, JavaScript decorators have reached Stage 3. They enable class-level metaprogramming without Babel hacks: logging, memoization, validation and dependency injection as reusable annotations.

15 min read Class · Method · Field · Accessor · addInitializer TC39 Stage 3 · TypeScript 5.x · Babel · Chrome 130+

1. What decorators do and why it took so long

JavaScript Decorators are a form of metaprogramming: they let you annotate and transform classes, methods, fields and accessors at definition time without touching the class body itself. The concept is familiar from other languages (Java annotations, Python decorators, C# attributes) and was simulated for years in JavaScript frameworks like Angular and NestJS through TypeScript decorators and Babel plugins. Standardization through TC39 proved difficult because the original Stage 2 specification (2016) had fundamental design problems and had to be completely reworked.

The new Stage 3 specification (since 2022, with native support from Chrome 130, Safari 18 and Firefox 131) is incompatible with the old TypeScript experimentalDecorators mode. That creates confusion: JavaScript Decorators in the TC39 sense and TypeScript decorators with experimentalDecorators: true are different things. Anyone working with TypeScript 5.0+ today who does not explicitly set experimentalDecorators is using the new TC39 semantics, and needs to know the differences to avoid migration pitfalls.

2. The core principle: decorators as functions

A JavaScript Decorator is a function that is invoked when a class or class element is defined. It receives the entity being decorated as a parameter, along with a context object that holds metadata about that entity. The return value replaces the original entity, or returns nothing if no transformation takes place. The context object (context) contains kind (the type: 'class', 'method', 'field', 'accessor', 'getter', 'setter'), name (the identifier), static and private (booleans), and addInitializer(fn), a function used to register code that runs at instance initialization time.

The crucial difference from the old Stage 2 specification: JavaScript Decorators in the new specification have no access to prototype descriptor objects and cannot add new properties to the class. They can only replace what they decorate, or run code at instantiation time via addInitializer. This is intentionally restrictive, it makes decorators safer and more predictable. Anyone who wants to add properties at compile time should use class fields or static blocks instead.


// The anatomy of a JavaScript Decorator (TC39 Stage 3 spec)

// A method decorator receives: (value, context)
// value = the original method function
// context = { kind, name, static, private, addInitializer, access }
function readonly(value, context) {
  if (context.kind === 'method') {
    // Return a replacement function, or return nothing for annotation-only
    return function (...args) {
      return value.apply(this, args);
    };
  }
}

// A class decorator receives: (value, context)
// value = the class constructor
// Return a new class or undefined
function sealed(value, context) {
  if (context.kind === 'class') {
    Object.seal(value.prototype);
    return value; // Return transformed class
  }
}

// Usage: decorator applied at class definition time
@sealed
class ApiClient {
  baseUrl = 'https://api.mironsoft.de';

  @readonly
  fetch(endpoint) {
    return globalThis.fetch(`${this.baseUrl}${endpoint}`);
  }
}

3. Class Decorators: transforming classes

Class Decorators are applied to the class as a whole and can replace, extend or enrich the class with metadata. They receive the class constructor as their first parameter and can return a new class (or a new constructor). That enables patterns like automatically registering the class in a global registry, freezing the prototype, adding mixin behavior, or wrapping the constructor for singleton patterns.

An important concept with Class Decorators: they are applied in reverse order when multiple decorators are stacked. The bottommost decorator (closest to the class) runs first, the topmost runs last. That matches the mathematical composition principle f(g(x)), where g is applied first. For stateful decorators that need initialization logic, context.addInitializer(fn) is the right addition: the registered function runs after the constructor call, for every new instance.


// Class decorator: register class in a global service registry
const registry = new Map();

function injectable(value, context) {
  if (context.kind !== 'class') return;

  // Register class by its name in the global registry
  registry.set(context.name, value);

  // Wrap constructor to track instantiation
  return class extends value {
    constructor(...args) {
      super(...args);
      console.log(`[DI] Instantiating ${context.name}`);
    }
  };
}

// Class decorator: singleton pattern
function singleton(value, context) {
  let instance = null;
  return class extends value {
    constructor(...args) {
      if (instance) return instance;
      super(...args);
      instance = this;
    }
  };
}

@injectable
@singleton
class DatabaseConnection {
  constructor(url) {
    this.url = url;
    this.connected = false;
  }

  connect() {
    this.connected = true;
    console.log(`Connected to ${this.url}`);
  }
}

// Both references point to the same instance (singleton)
const db1 = new DatabaseConnection('postgres://localhost/app');
const db2 = new DatabaseConnection('ignored');
console.log(db1 === db2); // true

4. Method Decorators: wrapping methods

Method Decorators are the most commonly used decorator category. They receive the original method function and can return a replacement function that wraps the original. That enables cross-cutting concerns such as logging, performance measurement, retry logic, rate limiting, error handling and caching, without baking that logic into every method. A Method Decorator for logging, for example, measures execution time and logs arguments and return value, without the method itself knowing anything about it.

The context.addInitializer pattern is especially useful with Method Decorators for binding: a decorator can use addInitializer to ensure the method is automatically bound to the instance when passed around as a callback. That solves the classic this-binding problem in event listeners without a manual .bind(this) or arrow-function fields. This approach is cleaner than the solutions commonly used until now, because it is declarative and requires no boilerplate in every class.


// Method decorator: performance logging
function measure(value, context) {
  if (context.kind !== 'method') return;
  const methodName = context.name;

  return function (...args) {
    const start = performance.now();
    const result = value.apply(this, args);

    if (result instanceof Promise) {
      return result.finally(() => {
        const duration = (performance.now() - start).toFixed(2);
        console.log(`[Perf] ${String(methodName)}: ${duration}ms (async)`);
      });
    }

    const duration = (performance.now() - start).toFixed(2);
    console.log(`[Perf] ${String(methodName)}: ${duration}ms`);
    return result;
  };
}

// Method decorator: auto-bind to instance
function bound(value, context) {
  if (context.kind !== 'method') return;

  context.addInitializer(function () {
    // 'this' refers to the instance at initialization time
    this[context.name] = value.bind(this);
  });
}

class DataService {
  #baseUrl = 'https://api.mironsoft.de';

  @measure
  async fetchProducts(category) {
    const res = await fetch(`${this.#baseUrl}/products?category=${category}`);
    return res.json();
  }

  @bound
  handleClick(event) {
    // 'this' is always the DataService instance, even as a callback
    console.log(this.#baseUrl, event.target);
  }
}

const svc = new DataService();
// Safe to pass as callback without .bind(this)
document.addEventListener('click', svc.handleClick);

5. Field Decorators: initializing fields

Field Decorators are fundamentally different from method decorators: they don't receive a field value as their first parameter (fields have no value yet at definition time) and must instead return an initializer function that is called for each instance. The initializer function receives the initial field value and returns the new value. That enables patterns like validation, transformation and conversion of field values at instantiation.

A concrete example of Field Decorators: a @clamp decorator that ensures a numeric field stays within defined bounds. Or a @serialize decorator that registers a list of fields to be serialized in a class-level metadata store. The latter requires the interplay of a field decorator and a class decorator: the field decorator registers metadata, the class decorator reads it and adds a toJSON() method to the class. This pattern enables declarative serialization behavior without external schema definitions.

6. Accessor Decorators: controlling getters and setters

The accessor keyword is a new addition to class syntax that was introduced alongside JavaScript Decorators. accessor name = value defines a class field with automatically generated getter and setter that operate on a private internal variable. Accessor Decorators can wrap these getters and setters, for example to observe value changes (observable pattern), perform validation on set, or log access.

The Accessor Decorator receives an object with get and set methods (the original getter and setter) and can return a new object with replaced getter and setter. This is the cleanest pattern for reactive properties in vanilla JavaScript without a framework: an @observable decorator that dispatches a custom event on every value change gives classes reactive behavior with a single annotation, comparable to @property in Lit or @observable in MobX, but without external dependencies.


// Accessor decorator: observable property with CustomEvent
function observable(value, context) {
  if (context.kind !== 'accessor') return;

  const { get, set } = value;
  const eventName = `change:${String(context.name)}`;

  return {
    get() {
      return get.call(this);
    },
    set(newValue) {
      const oldValue = get.call(this);
      if (newValue === oldValue) return;

      set.call(this, newValue);

      // Dispatch a custom event on the element if it's a DOM node
      if (this instanceof EventTarget) {
        this.dispatchEvent(
          new CustomEvent(eventName, {
            bubbles: true,
            detail: { oldValue, newValue, property: context.name },
          })
        );
      }
    },
    init(value) {
      return value; // Initial value unchanged
    },
  };
}

// Accessor decorator: type validation
function typed(expectedType) {
  return function (value, context) {
    if (context.kind !== 'accessor') return;
    const { get, set } = value;

    return {
      get() { return get.call(this); },
      set(newValue) {
        if (typeof newValue !== expectedType) {
          throw new TypeError(`${String(context.name)} must be ${expectedType}, got ${typeof newValue}`);
        }
        set.call(this, newValue);
      },
      init(v) { return v; },
    };
  };
}

class ProductModel {
  @observable
  accessor name = '';

  @observable
  @typed('number')
  accessor price = 0;
}

const product = new ProductModel();
product.addEventListener('change:price', (e) => {
  console.log(`Price changed: ${e.detail.oldValue} → ${e.detail.newValue}`);
});
product.price = 29.99; // Fires change:price event
product.price = 'invalid'; // TypeError: price must be number

7. Practical patterns: logging, memoize, validate

The most valuable JavaScript Decorator patterns are the ones that keep cross-cutting concerns out of business logic. Logging is the classic example: instead of manually calling console.log in every method, you annotate critical methods with @log. Memoization solves the performance problem for pure functions that always return the same result for the same arguments: a @memoize decorator caches results in a WeakMap per instance. Input validation as a @validate decorator checks arguments against a schema before the actual method is invoked.

A particularly elegant pattern for JavaScript Decorators in web components: @eventHandler, which registers a method as an event listener and automatically binds it to this via addInitializer. Combined with the lifecycle of a custom element class, decorators can manage the entire event listener lifecycle (registration on connect, removal on disconnect) without that logic being visible in the class body. That makes web component classes considerably more readable.

8. Decorators in TypeScript 5.x vs. legacy decorators

TypeScript 5.0 implemented the TC39 Stage 3 semantics for JavaScript Decorators. Anyone who has set experimentalDecorators: true in tsconfig.json so far and uses decorators from NestJS, TypeORM or other frameworks is using the old, incompatible decorator system. Migration is not trivial: old and new decorator code cannot be mixed, and many frameworks did not yet support the new system at the time of the TypeScript 5.0 release.

The key differences: old TypeScript decorators receive target, propertyKey and descriptor, the new specification uses (value, context). Old decorators can add properties; new ones cannot. Old decorators are tied to reflect-metadata; new ones have their own metadata mechanism via Symbol.metadata. The recommendation for new projects: don't set experimentalDecorators and work directly with the TC39 semantics. For existing projects with framework dependencies, wait for the framework's migration guide.

Feature TC39 Stage 3 (new) TypeScript experimentalDecorators (old)
Signature (value, context) (target, key, descriptor)
Adding properties Not possible Possible
Metadata Symbol.metadata (native) reflect-metadata (polyfill)
addInitializer Yes (in context) No
accessor keyword Yes No
Native browser support Chrome 130+, Safari 18+ Never (transpile-only)

9. JavaScript decorators in the ecosystem comparison

The introduction of standardized JavaScript Decorators affects the entire framework ecosystem. Frameworks like NestJS, TypeORM and Angular, which rely heavily on decorators, need to migrate to the new semantics. That is a significant effort, because the old target/key/descriptor signatures must be replaced with value/context, and reflect-metadata is being superseded by Symbol.metadata. Angular from version 19 onward and TypeScript 5.x are already closer to the new semantics, but complete migrations take time.

For projects without framework dependencies, JavaScript Decorators are production ready today when using Babel with the corresponding plugin, or a modern browser (Chrome 130+, Safari 18+). For library authors, Decorators offer an elegant API surface: consumers annotate their classes declaratively, and the library implements the behavior. That is the principle behind ORMs, validation frameworks and DI containers, and with native JavaScript Decorators, these patterns are finally possible without transpiler dependencies.

Mironsoft

Modern JavaScript, TypeScript architecture and framework migration

Want to integrate JavaScript decorators into your project?

We implement TC39-compliant decorator libraries for cross-cutting concerns, migrate existing experimentalDecorators codebases, and design decorator APIs for reusable class annotations.

Decorator design

Logging, memoization, validation and DI as reusable annotations

Migration

Migrating from experimentalDecorators to TC39 Stage 3, safely and incrementally

Framework upgrade

Updating Angular, NestJS and TypeORM to the new decorator system

10. Summary

JavaScript Decorators in the TC39 Stage 3 specification are the native answer to the years-long boilerplate problem of metaprogramming in JavaScript. Class decorators transform classes, method decorators wrap methods for cross-cutting concerns, field decorators control field initialization, and accessor decorators give getters and setters full observability. The new (value, context) signature system with context.addInitializer and Symbol.metadata is cleaner and safer than the old TypeScript experimentalDecorators system, which relied on reflect-metadata.

The practical use of JavaScript Decorators pays off today for new projects using a Babel plugin or modern browsers (Chrome 130+, Safari 18+). For existing codebases with framework dependencies, waiting for framework-side migration guides is advisable. The three most powerful patterns, @measure for performance logging, @observable for reactive fields, and @bound for automatic this-binding, solve everyday problems in class architectures without polluting business logic with boilerplate.

JavaScript Decorators, the essentials at a glance

Signature

TC39 decorator: (value, context). context.kind: 'class', 'method', 'field', 'accessor'. context.addInitializer(fn) for instance init code.

Order

Multiple decorators are applied bottom to top. @b @a class Xa first, then b. Mathematical composition principle.

vs. legacy

TC39: no reflect-metadata, no adding properties, accessor keyword. experimentalDecorators: target/key/descriptor, properties possible, reflect-metadata.

Availability

Native: Chrome 130+, Safari 18+, Firefox 131+. Transpiled: Babel plugin, TypeScript 5.0+ without experimentalDecorators.

11. FAQ: JavaScript Decorators

1What are JavaScript Decorators?
Functions that transform classes, methods, fields or accessors at definition time. TC39 Stage 3, native from Chrome 130, Safari 18, Firefox 131.
2TC39 vs. experimentalDecorators?
Different signature, different capabilities, different metadata. Not compatible. TC39: value/context. Legacy: target/key/descriptor.
3Order of stacked decorators?
Bottom to top. @b @a class X, a is applied first, then b.
4What is context.addInitializer()?
Registers a function that runs after the constructor for each instance. Ideal for automatic event listener binding.
5What is the accessor keyword?
accessor name = value, an auto-property with getter and setter over a private variable. Accessor decorators can wrap these.
6Can a decorator add properties?
No, TC39 Stage 3 explicitly disallows this. Only replacing the decorated entity or adding initialization logic via addInitializer.
7Usable in production today?
Yes, with a Babel plugin or natively in Chrome 130+, Safari 18+, Firefox 131+. TypeScript 5.0+ without experimentalDecorators.
8What is Symbol.metadata?
A well-known symbol for class-wide decorator metadata. Replaces reflect-metadata. Enables DI and serialization without external polyfills.
9Writing a memoize decorator?
A WeakMap per instance, serialize arguments as a key, return the cached result if present, otherwise call and cache.
10NestJS and Angular with TC39 decorators?
Migration in progress, core features still use experimentalDecorators. Consult the framework migration guide, don't mix systems.