Class decorators run before class static side is fully defined when downleveling
🔎 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.
If I understand the spec and current behavior (with no decorators) correctly, this is the correct behavior:
- The decorator expressions (e.g.,
fn()call in@fn()) are evaluated and stored in locals. - Field keys are evaluated (i.e., computed key expressions, if any).
- Class object is created, but not yet fully defined.
- Non-field properties and privates configured (i.e., methods,
get/set/accessordeclarations). - Class element decorators evaluated in order.
- → Class decorators evaluated. ←
- Class static elements initializers and extra initializers evaluated in order (possibly interleaved with custom static blocks).
- → Static fields are fully defined and initialized. ←
- 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);
}
}