TypeScript Decorators: Standard vs Legacy

Decorators are the one TypeScript topic where history actively confuses beginners: two incompatible implementations share the same syntax. Since TypeScript 5.0, the standard (Stage 3 / TC39) decorators are supported without any flag; the old experimentalDecorators implementation still exists for legacy frameworks. Knowing which one you are using — and why — is the whole lesson.

Why decorators exist

A decorator attaches behavior or metadata to a class declaration or its members by annotating them with @expression. The problem they solve is cross-cutting concern injection: logging, validation, caching, dependency registration — code that is not the method's business logic but must run around it anyway.

The problem: the same boilerplate everywhere

Purpose of the example: show the boilerplate decorators replace, so you can judge whether they earn their cost.

// Explicit wrapper style — what you write without decorators:
class OrdersService {
  // Every method repeats the same three concerns: timing, errors, audit.
  async create(input: CreateOrder): Promise<Order> {
    logger.info("OrdersService.create called");       // cross-cutting: logging
    try {
      const order = await this.repo.insert(input);
      audit.record("order.created", order.id);         // cross-cutting: audit
      return order;
    } catch (err) {
      logger.error("OrdersService.create failed", err);
      throw err;
    }
  }
}

What you should see: nothing — that is the point. The business logic (repo.insert) is buried in infrastructure. Every new method copies the ritual, and forgetting one line silently drops a concern.

The decorator version: what changes

Purpose of the example: the same behavior, declared once and attached with annotations.

class OrdersService {
  @logged                    // attached: wraps the method with timing + errors
  @audited("order.created")  // attached: records the domain event on success
  async create(input: CreateOrder): Promise<Order> {
    return this.repo.insert(input);   // only the business logic remains
  }
}

What you should see: the method body now states its single responsibility; the cross-cutting policies are declarative. The trade is explicit for the reader — but the behavior now lives outside the file's main flow, which is exactly the "risk zones" discussion later in this lesson.

Standard versus legacy: two implementations

This is the fork in the road. The syntax is identical; the semantics and the required configuration are not.

How the two differ

Standard (TC39 Stage 3)Legacy experimental
Available sinceTypeScript 5.0 (2023)TypeScript 1.5
Configurationnone — on by default"experimentalDecorators": true
Can replace methods?yes — via a return valueyes — via mutating a passed descriptor
Signature stylewraps and replaces (function in, function out)mutates target / PropertyDescriptor in place
Metadata (emitDecoratorMetadata)not supportedsupported (Angular, NestJS ecosystems)
JavaScript futureis the JavaScript standardnone — never standardized

What we learn: the two are not interchangeable. New code should target the standard implementation; you only reach for experimentalDecorators when a framework mandates it. Check tsconfig.json: if experimentalDecorators is present, you are writing legacy decorators.

Writing a standard decorator

Purpose of the example: a complete, runnable standard decorator — a method logger — showing the wrap-and-replace shape.

// A standard method decorator: receives (method, context), may return a
// replacement. No flag needed — TypeScript 5.0+ understands this natively.
function logged<This, Args extends any[], Return>(
  method: (this: This, ...args: Args) => Promise<Return>,
  context: ClassMethodDecoratorContext<
    This, (this: This, ...args: Args) => Promise<Return>
  >
) {
  const name = String(context.name);          // method name from the context
  return async function (this: This, ...args: Args): Promise<Return> {
    const start = performance.now();
    try {
      return await method.apply(this, args);  // call the original method
    } finally {
      const ms = (performance.now() - start).toFixed(1);
      console.log(`[perf] ${name} took ${ms}ms`);
    }
  };
}

class ReportService {
  @logged
  async generate(id: string): Promise<string> {
    await new Promise(r => setTimeout(r, 120)); // simulate work
    return `report-${id}`;
  }
}

new ReportService().generate("a1");

What you should see: [perf] generate took 121.4ms — the wrapper ran around the original without the class knowing. Note the types are precise: generics preserve this, argument types, and the return type, so the decorated method still checks perfectly at every call site. Legacy decorators cannot express this without any.

Decorator factories: decorators with options

@logged takes no arguments; @audited("order.created") does. The pattern is a decorator factory: a function that receives the options and returns the actual decorator.

Purpose of the example: parameterized decorators — the form frameworks like Angular and NestJS use everywhere.

// Factory: captures the event name, returns the real decorator.
function audited(event: string) {
  return function decorated<This, Args extends any[], Return>(
    method: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, typeof method>
  ) {
    return function (this: This, ...args: Args): Return {
      const result = method.apply(this, args);
      audit.record(event, { args });     // runs on every call
      return result;
    };
  };
}

class InvoiceService {
  @audited("invoice.sent")   // note the parentheses: factory invoked first
  send(id: string): boolean {
    return true;
  }
}

What you should see: identical behavior to a plain decorator, but configurable per usage site. Reading rule for code review: @audited("x") is two calls — the factory runs once at class-definition time, the returned decorator wraps the method.

The legacy trap: reading framework code

Older codebases and Angular/NestJS material show the legacy shape — a function receiving target and mutating it:

// LEGACY (experimentalDecorators) — recognize it, do not copy it:
function cached(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;          // mutate the descriptor in place
  descriptor.value = function (...args: any[]) {
    return original.apply(this, args);
  };
}

What you should see: three positional parameters and descriptor mutation — the fingerprint of the legacy implementation. It only runs when experimentalDecorators is enabled, and its types are necessarily loose (any). When a framework hands you decorators like @Injectable(), that is the legacy world plus emitDecoratorMetadata; use them as provided, but write your own decorators in the standard style.

Where decorators earn their cost

Decorators trade explicitness for declaration. That trade is good when the annotation names a stable, well-understood policy — and harmful when it hides nontrivial control flow.

Good use cases

  • Framework integration — route registration (@Get("/users")), dependency injection (@Inject()), where the decorator is the framework's public API.
  • Uniform cross-cutting policies — timing, retry, caching, audit: concerns applied identically across dozens of methods.
  • Declarative metadata — marking fields as required for validation or serialization, consumed by a library at runtime.

Risk zones

  1. Hidden control flow. Three stacked decorators execute bottom-up; an ordering bug is invisible at the call site. Keep stacks shallow and document order.
  2. Framework lock-in. Legacy decorators + metadata bind your classes to one ecosystem's runtime conventions.
  3. Debugging opacity. A wrapped method no longer appears verbatim in a stack trace-friendly form. The logged example above preserves names deliberately; naive wrappers do not.

Adoption policy

Adopt in a controlled module first: standard decorators only, a lint rule against mixing legacy signatures, and a team rule that every custom decorator has a doc comment stating where it runs (definition time vs call time). Measure readability with a newcomer before expanding usage.

Practice: two implementations of one policy

Build a retry decorator

Write a standard decorator factory @retry(3) that re-runs a failing async method with a short delay, using the logged example as the skeleton. What you should see: a method annotated @retry(3) succeeding on its second attempt while the caller writes no retry logic. Verify the types: the decorated method must still be callable with its original signature.

Compare with an explicit wrapper

Rewrite the same policy as a plain function withRetry(fn, 3) and call const send = withRetry(service.send, 3). What you should see: the same behavior with zero annotations — and the explicit version is arguably clearer at a single call site. The decorator wins when the policy applies to many methods uniformly; that judgment — annotation vs explicit wrapper — is exactly the design muscle this lesson builds.