The New JavaScript Decorators Are Not What You Remember

Standard decorators landed in TypeScript 5: no descriptors, a context object, and auto-accessors. Here's the API you'll actually use.

Share

If you learned decorators from NestJS, TypeORM, or a 2019 TypeScript tutorial, unlearn them. The decorators that actually made it into ECMAScript — and into TypeScript 5 — have a completely different API. Same @syntax, entirely new machinery underneath.

The old experimental proposal had you wrestling with PropertyDescriptor objects and generated __decorate helpers. The standard hands you the method itself, plus a context object full of hooks. It's simpler, and it does things the old API literally couldn't.

Why this matters

Decorators saturate backend TypeScript: dependency injection, ORM entities, route binding, validation. Nearly all of it targets the legacy "experimental" flavor, which is now a dead end.

The standard flavor is where the ecosystem is moving. Chromium ships it natively, TypeScript 5 compiles it for everything else, and Babel matches it. When you upgrade a framework or write a library, you'll meet this API — and mixing the two flavors fails in ways that are genuinely hard to debug.

How it works

A decorator is now a plain function taking two arguments: the decorated value and a context object.

The context tells you what you're decorating via kind — method, getter, field, accessor, or class — plus a name. To change behavior, return a replacement function. Return nothing and the original survives untouched.

addInitializer queues a callback that runs during construction, which is the right place to bind methods or register instances. You never touch a descriptor again.

The genuinely new trick is the accessor keyword. Write accessor token = "" and the runtime generates a getter/setter pair backed by a private slot. Your decorator receives that { get, set } pair and can wrap it — clean interception of writes, no Object.defineProperty gymnastics.

Factories survive unchanged: @trace(verbose) is still a function returning a decorator.

Where this helps

  • Wrapping service or repository methods with timing, retries, or auth checks without touching a single call site.
  • Write-time validation via accessor decorators: reject empty config values, clamp numbers, redact secrets on read.
  • Registration: an initializer that pushes instances into a registry, powering lightweight DI or event-bus wiring.
  • Library code that runs natively in Chromium and compiles identically through TS or Babel — one implementation, no flavor matrix.

Watch out

Legacy and standard decorators are incompatible in both directions. NestJS-style metadata doesn't exist under the new semantics. Check which flavor a package targets before stacking its decorators on standard ones.

In TypeScript, do not enable experimentalDecorators — that flag silently switches the compiler back to legacy behavior.

Decoration runs once, at class definition. You can't read instance state in a decorator; use addInitializer or the wrapper's closure instead.

Native support outside Chromium is uneven. Compile with TypeScript 5 or Babel if you ship broadly.

Try it yourself

// trace.ts — TypeScript 5+, target ES2022, no experimentalDecorators flag
// run: npx tsc trace.ts && node trace.js
function trace(method: Function, ctx: ClassMethodDecoratorContext) {
  const name = String(ctx.name);
  return function (this: unknown, ...args: unknown[]) {
    console.time(name);
    const out = method.apply(this, args);
    console.timeEnd(name);
    return out;
  };
}

class Invoices {
  @trace
  totalFor(user: string): number {
    return 42; // pretend this hits the DB
  }
}

new Invoices().totalFor("ada");
// totalFor: 0.031ms

TL;DR

  • What changed: ECMAScript standardized decorators; TypeScript 5 and Chromium ship a descriptor-free API built around a context object.
  • Why it matters: the legacy decorator ecosystem is frozen; new framework and library code will use the standard flavor.
  • Try today: write the @trace method decorator above and notice you never touched a PropertyDescriptor.