typescripttypescript-decorator

Class method decorator with a generic class method


I am trying to get a class method decorator that works just fine with a normal class method to work with a generic class method.

I have this code typescript playground link:

abstract class WorkerBase {
  log(...arguments_: unknown[]) {
    console.log(arguments_);
  }
}

const span =
  <
    This extends WorkerBase,
    Target extends (this: This, ...arguments_: unknown[]) => Promise<unknown>,
  >() =>
  (target: Target, context: ClassMethodDecoratorContext<This, Target>) =>
    async function replacementMethod(
      this: This,
      ...originalArguments: unknown[]
    ) {
      this.log(['called', this.constructor.name, context.name]);
      return target.call(this, ...originalArguments);
    };

class ExterneWorker extends WorkerBase {
  @span()
  protected async get<Return>() {
    return undefined as unknown as Return;
  }
}

And compilation fails with:

Decorator function return type '(this: ExterneWorker, ...originalArguments: unknown[]) => Promise<unknown>' is not assignable to type 'void | (<Return>() => Promise<Return>)'.
  Type '(this: ExterneWorker, ...originalArguments: unknown[]) => Promise<unknown>' is not assignable to type '<Return>() => Promise<Return>'.
    Type 'Promise<unknown>' is not assignable to type 'Promise<Return>'.
      Type 'unknown' is not assignable to type 'Return'.
        'Return' could be instantiated with an arbitrary type which could be unrelated to 'unknown'.

The span decorator works just fine with a non-generic class method, but it fails with a generic one. I don't really understand what TypeScript is telling me. I think something is wrong with the Return definition in my span decorator definition.


Solution

  • The problem is mostly that the type of the get() method is <R>() => Promise<R>, which means that it somehow returns any type the caller wants. Such a method cannot possibly be implemented safely, and according to a sound type system, it is equivalent to () => never, where the never type is the bottom type assignable to any other type. This is exactly the opposite of the unknown type. So () => unknown is not assignable to the get() method type, and the compiler correctly balks.

    As an example, if you can call worker.get<string>() and get a Promise<string>, and call worker.get<number>() and get a Promise<number>, that means that worker.get() at runtime somehow produces a Promise which is both a string and a number. This is impossible. There are no string & number values. It's equivalent to never.


    If you want to use that type anyway, it means you need your span() to accept a method of any function type, even those unsafe ones like () => never and <R>() => Promise<R>. The easiest approach there is to use an unsafe type... the any type:

    const span = <
      Target extends (...args: any) => Promise<any>,
    >() =>
      (
       target: Target, 
       context: ClassMethodDecoratorContext<ThisParameterType<Target>, Target>
      ) =>
        async function replacementMethod(
          this: ThisParameterType<Target>,
          ...originalArguments: Parameters<Target>
        ) {
          console.log(['called', context.name]);
          return target.call(this, ...originalArguments);
        };
    
    class ExterneWorker extends WorkerBase {
      @span() // okay
      async get<Return>() {
        return undefined as unknown as Return;
      }
    }
    

    This is the same as yours except that I use the ThisParameterType utility type to compute the type you referred to as This instead of trying to infer it.

    Yes, any is unsafe, but (...args: any) => any matches just about any function without worrying about the arguments or return type. Trying to make an any-free version is possible, but then it won't play nicely with already-unsafe types like <R>() => R.

    Playground link to code