EdgeCases Logo
Mar 2026
JavaScript
Expert
8 min read

JavaScript Decorators (TC39 Stage 3): The Breaking Change from Legacy

TC39 Stage 3 decorators are incompatible with TypeScript's legacy implementation — here's what changed

javascript
decorators
tc39
typescript
metaprogramming
migration

JavaScript decorators have been in limbo for over eight years. TypeScript shipped experimental decorators in 2015 based on an early spec draft, Angular and NestJS built empires on them, and then TC39 changed everything. The Stage 3 proposal that's landing in browsers is fundamentally incompatible with what you've been using — and migrating isn't straightforward.

What Decorators Actually Are

Decorators are functions that receive a value being defined and can replace or augment it. They run at class definition time, not instantiation:

@logged
class MyClass {
  @reactive accessor count = 0;
  
  @bound
  handleClick() {}
}

// Decorators are just functions
function logged(value, context) {
  console.log(`Defining: ${context.name}`);
  return value;
}

The key insight: decorators can only replace a value with something of the same kind. A method decorator returns a method, a class decorator returns a class. No more magical transformations.

The Legacy vs. Stage 3 Incompatibility

TypeScript's experimental decorators (enabled via experimentalDecorators) use a completely different signature:

// ❌ Legacy TypeScript decorator signature
function legacyDecorator(
  target: any,              // The class prototype
  propertyKey: string,      // Method name
  descriptor: PropertyDescriptor  // Property descriptor
) {
  // Mutates descriptor
}

// ✅ Stage 3 decorator signature
function modernDecorator(
  value: Function,          // The actual method
  context: {
    kind: 'method' | 'getter' | 'setter' | 'field' | 'accessor' | 'class';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access?: { get(): unknown; set?(v: unknown): void };
    addInitializer(fn: () => void): void;
  }
) {
  return value; // Return replacement or undefined
}

The fundamental difference: legacy decorators mutate a property descriptor, Stage 3 decorators receive the value directly and return a replacement.

The context Object

Every decorator receives a context object with metadata about what's being decorated:

  • kind — What type of element: 'class', 'method', 'getter', 'setter', 'field', 'accessor'
  • name — The element's name (string or symbol)
  • static — Whether it's a static member
  • private — Whether it's private
  • access — Object with get/set for accessing the value on instances
  • addInitializer — Register code to run during initialization
function debug(value, context) {
  console.log(`Decorating ${context.kind}: ${String(context.name)}`);
  console.log(`Static: ${context.static}, Private: ${context.private}`);
  return value;
}

class Example {
  @debug x = 1;           // kind: 'field', name: 'x'
  @debug static y = 2;    // kind: 'field', name: 'y', static: true
  @debug #z = 3;          // kind: 'field', name: '#z', private: true
}

The accessor Keyword

Stage 3 introduces a new class element — auto accessors — that decorators can target:

class Counter {
  accessor count = 0;  // Creates getter/setter backed by private storage
}

// Equivalent to:
class Counter {
  #count = 0;
  get count() { return this.#count; }
  set count(v) { this.#count = v; }
}

This exists because field decorators can't intercept get/set — they only see initialization. Auto accessors provide the hook point:

function reactive(value, context) {
  if (context.kind !== 'accessor') {
    throw new Error('@reactive only works on accessors');
  }
  
  return {
    get() {
      track(this, context.name);
      return value.get.call(this);
    },
    set(v) {
      value.set.call(this, v);
      trigger(this, context.name);
    },
    init(initialValue) {
      return initialValue;
    }
  };
}

class State {
  @reactive accessor count = 0;  // Now tracked!
}

Decorator Evaluation Order

Decorators evaluate in a specific order that matters for composition:

  1. Decorator expressions evaluate top-to-bottom, left-to-right (interleaved with computed property names)
  2. Decorators call bottom-to-top (inner first, like function composition)
  3. Results apply all at once after all decorators run
function a(name) {
  console.log(`eval @${name}`);
  return (v, ctx) => {
    console.log(`call @${name}`);
    return v;
  };
}

class C {
  @a('outer') @a('inner')
  method() {}
}

// Logs:
// eval @outer
// eval @inner
// call @inner  (bottom-to-top calling!)
// call @outer

addInitializer: The Hidden Power

addInitializer lets you register code that runs during construction:

function bound(value, context) {
  if (context.kind !== 'method') {
    throw new Error('@bound only works on methods');
  }
  
  context.addInitializer(function() {
    // 'this' is the instance being created
    this[context.name] = this[context.name].bind(this);
  });
  
  return value;
}

class Button {
  @bound
  handleClick() {
    console.log(this); // Always the Button instance
  }
}

const btn = new Button();
const handler = btn.handleClick;
handler(); // 'this' is correctly bound

Migration Strategy

If you're using legacy decorators, you can't just swap. Libraries need to support both:

// Detect signature by argument count/shape
function logged(...args) {
  // Legacy: (target, key, descriptor)
  if (args.length === 3 && typeof args[1] === 'string') {
    const [target, key, descriptor] = args;
    const original = descriptor.value;
    descriptor.value = function(...a) {
      console.log(`Calling ${key}`);
      return original.apply(this, a);
    };
    return descriptor;
  }
  
  // Stage 3: (value, context)
  const [value, context] = args;
  if (context.kind === 'method') {
    return function(...a) {
      console.log(`Calling ${context.name}`);
      return value.apply(this, a);
    };
  }
  return value;
}

The TC39 proposal explicitly recommends this dual-support pattern during transition.

What's NOT Supported

Stage 3 decorators deliberately omit some legacy capabilities:

  • Parameter decorators — No @inject on constructor params
  • Decorator metadata emissionemitDecoratorMetadata has no equivalent
  • Arbitrary value transformation — Can't turn a method into a property

Angular, NestJS, and TypeORM heavily use parameter decorators and metadata. Their migration paths involve significant rewrites or shims.

Current Support

Stage 3 decorators are available in:

  • TypeScript 5.0+ — Without experimentalDecorators flag
  • Babel — Via @babel/plugin-proposal-decorators with version: "2023-11"
  • Browsers — Not yet (waiting for Stage 4)
  • Node.js — Not yet (type stripping won't help — needs actual transformation)

For production, transpilation is still required. Native support is expected once the proposal reaches Stage 4 and implementations ship.

Advertisement

Related Insights

Explore related edge cases and patterns

JavaScript
Expert
Explicit Resource Management: using and Symbol.dispose
8 min
Browser APIs
Surface
Web Workers Structured Clone: What Can't Cross the Boundary
6 min
TypeScript
Expert
TypeScript Distributive Conditional Types: The Union Distribution Rule
8 min

Advertisement