TypeScript: ES2022 Class Decorators not working when Class has self-static member

Bug Report

Class decorators are not working properly when the class has a static field of the same type and is instantiating it statically. Seems like this only happens when targeting ES2022.

🔎 Search Terms

Various combinations of the below search strings. Haven’t been able to find anything useful. Been searching for several days now, these are just the most recent ones I could remember:

  • Typescript class decorator with static property not working
  • Typescript decorators: TypeError: undefined is not a constructor
  • Typescript decorators not working (with es2022)
  • Typescript decorator es2022
  • Angular custom decorators not working
  • Angular custom decorators with aot

🕗 Version & Regression Information

Regressed when tsconfig.compilerOptions.target is ES2022. Other ES versions that I tried seem to be working as expected. Tried Nightly version in the playground, could still repro it (after I changed target to ES2022 in TSConfig).

⏯ Playground Link

Bug repro in Playground You can try setting e.g. ES2021, and the code works, but not on ES2022.

💻 Code

src\index.ts:

function Message(ctor: Function)
{
    console.log(`Registring: "${ctor.name}"`);
}

@Message
class PingMessage
{
    public static Default: PingMessage = new PingMessage(); // [A]
    public constructor(public readonly Value: string = "Ping") { }
}

// PingMessage.Default = new PingMessage(); // [B]
console.log(PingMessage.Default.Value);

// Swapping instantiation from [A] to [B] renders this code working.
// Seems like constructor is not defined well while instantiating a static member within its own class.

package.json:

{
  "name": "decorator-test",
  "version": "1.0.0",
  "description": "Test App",
  "scripts": {
    "start": "(if exist dist rd /S /Q dist) >nul 2>nul && tsc && node ./dist/index.js"
  },
  "dependencies": {
    "tslib": "^2.4.1"
  },
  "devDependencies": {
    "typescript": "^4.9.4"
  }
}

tsconfig.json:

{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist",
    "target": "ES2022",  // <----- !!!
    "module": "ES2022",
    "strict": true,
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "lib": [
      "ES2022",
      "dom"
    ]
  },
  "files": [
    "src/index.ts"
  ]
}

🙁 Actual behavior

~\dist\index.js:13 static { this.Default = new PingMessage_1(); } // [A]           ^

TypeError: undefined is not a constructor  at Function.<static_initializer> (~\dist\index.js:13:29)  at Object.<anonymous> (~\dist\index.js:12:19)  at Module._compile (node:internal/modules/cjs/loader:1155:14)  at Object.Module._extensions…js (node:internal/modules/cjs/loader:1209:10)  at Module.load (node:internal/modules/cjs/loader:1033:32)  at Function.Module._load (node:internal/modules/cjs/loader:868:12)  at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)  at node:internal/main/run_main_module:22:47

🙂 Expected behavior

Console.logs:

Registring: "PingMessage"
Ping

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 17
  • Comments: 17 (6 by maintainers)

Most upvoted comments

Came across this issue recently. I believe it is in fact a TS bug that needs to be resolved. The issue is caused by the aliased class name, which is not initialised at the time of the static initialisers being called, where the class itself is, in fact, available from within the static initialisers in ES2022.

Given the following TS Code:

function createFromClass(caller: string, classRef: unknown) {
    console.log(`${caller}:`, classRef);
    return 'foo';
}

function SomeDecorator(): ClassDecorator {
    return () => { };
}

class UndecoratedClass {
    static test = createFromClass('UndecoratedClass', UndecoratedClass);
}

@SomeDecorator()
class DecoratedClass {
    static test = createFromClass('DecoratedClass', DecoratedClass);
}

If this is run targeting ES2021 or earlier then the class is available to the static initialisers for both UndecoratedClass and DecoratedClass. But, if we target ES2022 or later then the DecoratedClass class receives undefined as its reference to itself from within its static initialisers. This is a result of the intermediary ..._1 class reference in the generated code not being populated yet:

let DecoratedClass = DecoratedClass_1 = class DecoratedClass {
    static test = createFromClass('DecoratedClass', DecoratedClass_1);
};
DecoratedClass = DecoratedClass_1 = __decorate([ SomeDecorator() ], DecoratedClass);

When there is no decorator, then the intermediary reference is not created and it all works as expected.

Here is a playground of the above example: https://www.typescriptlang.org/play?target=9#code/GYVwdgxgLglg9mABBATgUwIZTQMRXAWwGEAbDAZ3IAoIMSS0UAuRcqFGMAcwBpkzKAJTTAW4ANZg4AdzABKRAG8AUIjXIE5OAwB0JOFyoADACSLa9RgF8mRvhAHlhwOQG5V69FBAokAcmA4OD93K2VlUEhYBEQAZUI0ABE0CDgULDSqORZSCnJk1PSoNKUPNS8fJCzEAF4APiVEK1DwhzzEAFUwABMUtKw0btzKUvVWKCwYCERsNlrkdAG8QmHqPy7ewoGhxz8+Db6iwdW3ZTDlAAF4giTDjJQs5TaRgv7sHfaVMbZJ6dmoeaoTDYZbERxUPyvI4fSh7RBQ7YnUJAA

Proposed solution

Not sure all the reasons for the intermediary class reference, but potentially an alternative would be for TS to generate the following JS code when a decorator is used:

let DecoratedClass = (() => {
    class DecoratedClass {
        static {             
            this.test = createFromClass('DecoratedClass', DecoratedClass); 
        }
    };
    return __decorate([ SomeDecorator() ], DecoratedClass);
})()

The code above effectively uses a shadowed variable name within the IIFE as the intermediary class reference to the same effect, but without the reference initialisation timing issue. This even allows for less of a need for modification of the decorated class code and the final code has fewer characters (ignoring whitespace). @rbuckton What do you think of this proposed approach for legacy decorators?

(EDIT: It could even be trimmed down to the following, but it really depends on the reason for the intermediary reference as to how far we simplify this.)

let DecoratedClass = (() => 
  __decorate([ SomeDecorator() ],
    class DecoratedClass {
      static {
        this.test = createFromClass('DecoratedClass', DecoratedClass);
      }
    }
  )
)();

Will this be backported to 4.9? We are running into this issue in Angular 15 which uses TS 4.9 and targets ES2022 by default.

I am seeing more and more Angular projects running into this issue. @rbuckton is there any plan to address this issue soon? Hopefully my proposed solution above could provide some ideas.

Ok, I think I have got a solution that will work for all scenarios. I reworked the playground to make the expectations clearer too: See here

Trimmed TS example code:

console.log('=== Declaring: Original');

@SomeDecorator()
class Original {
    static test1 = init('Original', Original);
    static test2 = Original;
    static test3() { return Original; }
    static test4 = doNothing(function () { return Original });
    static test5 = Original?.test4();
}

console.log('--- After Declaration, should be Wrapped:', Original.name);
console.log(`'test1' should be Original:`, Original.test1?.name);
console.log(`'test2' should be Original:`, Original.test2?.name);
console.log(`'test3()' should return Wrapped:`, Original.test3()?.name);
console.log(`'test4()' should return Wrapped:`, Original.test4()?.name);
console.log(`'test5' should be Original:`, Original.test5?.name);

When run using ES2021, the output is:

[LOG]: "=== Declaring: Original" 
[LOG]: "In initialiser, should be 'Original':",  "Original" 
[LOG]: "INIT Decorator" 
[LOG]: "Decorating class:",  "Original" 
[LOG]: "--- After Declaration, should be Wrapped:",  "Wrapped" 
[LOG]: "'test1' should be Original:",  "Original" 
[LOG]: "'test2' should be Original:",  "Original" 
[LOG]: "'test3()' should return Wrapped:",  "Wrapped" 
[LOG]: "'test4()' should return Wrapped:",  "Wrapped" 
[LOG]: "'test5' should be Original:",  "Original"

All expectations align.

But, when run using ES2022, the output is:

[LOG]: "=== Declaring: Original" 
[LOG]: "In initialiser, should be 'Original':",  undefined 
[LOG]: "INIT Decorator" 
[LOG]: "Decorating class:",  "Original" 
[LOG]: "--- After Declaration, should be Wrapped:",  "Wrapped" 
[LOG]: "'test1' should be Original:",  undefined 
[LOG]: "'test2' should be Original:",  undefined 
[LOG]: "'test3()' should return Wrapped:",  "Wrapped" 
[LOG]: "'test4()' should return Wrapped:",  "Wrapped" 
[LOG]: "'test5' should be Original:",  undefined 

Many cases are broken for ES2022.

Here is the current code emitted for the ES2022 target:

let Original = Original_1 = class Original {
    static { this.test1 = init('Original', Original_1); }
    static { this.test2 = Original_1; }
    static test3() { return Original_1; }
    static { this.test4 = doNothing(function () { return Original_1; }); }
    static { this.test5 = Original_1?.test4(); }
};
Original = Original_1 = __decorate([
    SomeDecorator()
], Original);

I think the best solution that covers all bases here is to add a static initialiser block at the top of the emitted class:

    static { Original_1 = this; }

Now the alias will be available to all the static initialiser blocks. Everything works!

Here is an example of the tweaked JS output code (playground) with just this one line added:

let Original = Original_1 = class Original {
    static { Original_1 = this; } // <-- PROPOSAL TO ADD THIS LINE
    static { this.test1 = init('Original', Original_1); }
    static { this.test2 = Original_1; }
    static test3() { return Original_1; }
    static { this.test4 = doNothing(function () { return Original_1; }); }
    static { this.test5 = Original_1?.test4(); }
};
Original = Original_1 = __decorate([
    SomeDecorator()
], Original);

@rbuckton what do you think of this solution?

PS. potentially the line: let Original = Original_1 = class Original {
could now be simplified: let Original = class Original {
because the alias is assigned internally. Although this is just a bundle size optimization.

I agree that a backport would be really great. This bug is coming up many times in the Angular community.

This is still an issue with Angular 16.1.4 and Typescript 5.0.4 and a tsconfig with lib: es2022, module: es2022 and target of es2022. It’s fine with a target of es2021.

According to the Angular upgrade matrix (https://gist.github.com/LayZeeDK/c822cc812f75bb07b7c55d07ba2719b3), for Angular 16.x.x, you must use Typescript >=4.9.5 <5.1.0

Has anyone tried Typescript >=5.1.0 with Angular ^16.1.4?

If no one knows the compatibility results, I’ll give it a try and see what happens.

In general we only backport critical crashes or regression specific to a particular release, or issues without any workarounds. This doesn’t appear to qualify for any of those criteria.