Typescript Method Decorator has Strange Behaviour

830 views Asked by At

I have this sample of code that illustrates a weird thing happening with my code, and I can't figure a reason why this would happen.

Basically, I have a simple class, Foo, that contains only one method. A greet method, that by default when called simply logs, "Hello, World". But I have a decorator applied to the method that changes the method. The new function is almost the same, but takes a string argument to determine what to log.

Here is the full code of my example.

const arr = [];

function methodOverride(target, propertyKey, descriptor) {
    descriptor.value = function(str) {
        console.log("Hello,", str);
    };
    return descriptor;
}


class Foo {
    
    @methodOverride
    greet() {
        console.log("Hello, World");
    }
}

let foo = new Foo();
arr.push(foo);

// foo.greet(); Will result in "Hello, undefined"
// foo.greet("FooBar"); Will result in error.
arr[0].greet("FooBar");

In my example, you can see I create a Foo instance, foo, and push it into an array. I'll get back to why I am doing that. But If I try to run foo.greet() it will log, when the compiled js is executed, Hello, undefined. It's clearly expecting an argument because the method was overridden.
But if I pass an argument to it, foo.greet("FooBar");, Typescript will throw an error error TS2554: Expected 0 arguments, but got 1.

The even stranger thing is if I try to execute the method from the object that was inserted into the array, it works fine. The last line of code in the example, arr[0].greet("FooBar"); will log, Hello, FooBar

Also, if I take that object out of the array and store it in a another variable, call it bar, then it passing the argument will work with no erorrs.

Adding this under my code example will get the results listed in the comment.

let bar = arr[0];
bar.greet(); // Hello, undefined
bar.greet("FooBar"); // Hello, FooBar

I hope these examples are clear enough for anyone to follow. I am new to TypeScript decorators, and I'm not sure if there is something about decorators that I am just not getting, or perhaps the problem lies within the Transpiler.

I am using https://www.npmjs.com/package/typescript installed globally to transpile TS to JS. The version I am using is 3.9.7.

Here is the command I am using to transpile: tsc -p tsconfig.json

And here is my tsconfig.json file.

{      
    "compilerOptions": {
        "outDir": "./",
        "noEmitOnError": true,
        "experimentalDecorators": true,
        "target": "es2020",
        "watch": true,  
    }, 
    "exclude":  []  
}

One of the other reasons I think it might be the transpiling that is erroneously throwing the error is that if I set noEmitOnError: false then when the compiled JS runs I will have the expected results logged to the console.

Any help would be appreciated. Thank you.

1

There are 1 answers

4
jcalz On

What you're missing is that decorators do not mutate the type of the things they decorate. Since the greet() method of Foo is declared to be of type ()=>void, the compiler continues to think it is of type ()=>void even after you have decorated it. The decoration does not cause the compiler to mutate it to (str: string)=>void.

There is an open issue in GitHub, microsoft/TypeScript#4881, asking for support for decorator type mutation (the issue title talks about class decorators mutating the class type, but the issue is also being used to track support for method decorators mutating the method type). A major stumbling block in getting this implemented is that the TC39 proposal for adding decorators to JavaScript has been in Stage 2 for several years now. TypeScript generally doesn't like to commit to implementing proposals before Stage 3, which is where the general behavior of a feature tends to be mostly solidified. Unfortunately, the TC39 proposal is changing significantly from what TypeScript has implemented. And therefore TypeScript is probably not going to do anything further with decorators until the TC39 proposal settles down and advances to Stage 3.

For now, there's not a workaround I like much. Class decorator mutation can be simulated soundly just by using the decorator as a plain function. But method decorator mutation is not as easily simulated in a type safe way. So we might need to fall back to the equivalent of type assertions or worse to cajole the compiler into accepting what you're doing.

For example, one thing you could do is to use a single overload to manually tweak the call signature to correspond to what you expect it to be. If the implementation is not compatible with that call signature, you might also need to add a //@ts-ignore comment before the call signature to suppress it. It's not pretty, but it at least lets you continue using your decorator and move forward with life:

class Foo {
  //@ts-ignore
  greet(str: string): void;
  @methodOverride
  greet(x: number) {
    console.log("Hello, World");
  }
}

let foo = new Foo();
foo.greet("FooBar"); // okay, no error

Playground link to code