TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Class decorators run before class static side is fully defined when downleveling

Open james-pre opened this issue 7 months ago • 1 comments

🔎 Search Terms

decorator downlevel decorator static

🕗 Version & Regression Information

Tested on TypeScript 5.6.3, 5.7.3, 5.8.3 with targets ES2022 and ES2024

⏯ Playground Link

https://www.typescriptlang.org/play/?useDefineForClassFields=true&target=11#code/LAKAZgrgdgxgLgSwPZQAQBMCmMkCcCGcmAFHPrgOaZwBcqx+ARgM5wHypSYDu9AdAPIVmdfFACeAbQC6ASlQBeAHyox4+QDJUAb1QAPURNQBfedtCpUOKMyQAbTHztIKxAOQANVAmao3qAGpUMkpqPj1ZAG5QY1BQAAEsHAIiUBg7fGZfAFE9fABbAAcHHQtUVkIEGH1FP3F8cTc4kGMgA

💻 Code

function decorate(target: (abstract new (...args: any[]) => any) & { x: any }) {
  console.log('X is ' + target.x);
}

@decorate
class Example {
  static x = 'yay'
}

🙁 Actual behavior

Class decorators are run before the class definition is finished executing. In the example, this results in the log message X is undefined.

🙂 Expected behavior

As per the the stage 3 decorators proposal:

The result of decorators is stored in the equivalent of local variables to be later called after the class definition initially finishes executing.

Consequently, the example should log the message X is yay

Additional information about the issue

The emitted JS places static initialization blocks before the class body from source:

let Example = (() => {
    let _classDecorators = [decorate];
    let _classDescriptor;
    let _classExtraInitializers = [];
    let _classThis;
    var Example = class {
        static { _classThis = this; }
        static {
            const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
            __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
            Example = _classThis = _classDescriptor.value;
            if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
        }
        static x = 'yay';
        static {
            __runInitializers(_classThis, _classExtraInitializers);
        }
    };
    return Example = _classThis;
})();

A potential solution would be to move these blocks after the source code of the class body, like so:

let Example = (() => {
    let _classDecorators = [decorate];
    let _classDescriptor;
    let _classExtraInitializers = [];
    let _classThis;
    var Example = class { 
        static x = 'yay';
        static { _classThis = this; }
        static {
            const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
            __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
            Example = _classThis = _classDescriptor.value;
            if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
        }
        static {
            __runInitializers(_classThis, _classExtraInitializers);
        }
    };
    return Example = _classThis;
})();

Also, this does not occur with the ESNext target since decorators are no longer downleveled.

james-pre avatar Jun 13 '25 21:06 james-pre

If I understand the spec and current behavior (with no decorators) correctly, this is the correct behavior:

  1. The decorator expressions (e.g., fn() call in @fn()) are evaluated and stored in locals.
  2. Field keys are evaluated (i.e., computed key expressions, if any).
  3. Class object is created, but not yet fully defined.
  4. Non-field properties and privates configured (i.e., methods, get/set/accessor declarations).
  5. Class element decorators evaluated in order.
  6. → Class decorators evaluated. ←
  7. Class static elements initializers and extra initializers evaluated in order (possibly interleaved with custom static blocks).
  8. → Static fields are fully defined and initialized. ←
  9. Class extra initializers are evaluated.

I.e., to log X is yay, the code should be:

function decorate(target: ..., ctx: ClassDecoratorContext<...>) {
  ctx.addInitializer(function () {
    console.log('X is ' + this.x);
  });
}

@decorate
class Example {
  static x = 'yay'
}

Original code with no decorators:

class Example {
  static {
    console.log('X is ' + this.x);
  }
  static x = 'yay'
}

Equivalent to the code with addInitializer:

class Example {
  static x = 'yay'
  static {
    console.log('X is ' + this.x);
  }
}

miyaokamarina avatar Dec 09 '25 12:12 miyaokamarina