Search code examples
typescriptcontravariancetypescript-decorator

Is there a way to type a TypeScript method decorator to restrict the type of the method it can decorate?


I want to write a TypeScript method decorator that can only be applied to methods with a certain type of first argument. It's a common pattern in the codebase I'm working in to pass around a request context that has handles for database, metrics, logging, etc. I'd like to write a decorator that requires one of these resources in the request context, but is otherwise agnostic to the shape of the request context.

Here's a stylized example:

interface MyResource {
    logMetricsEtc(...args: any): void;
}

interface HasResourceINeed {
    myResource: MyResource;
}

function myDecorator<TFn extends ((tContext: HasResourceINeed, ...rest: any) => any)>(
    _target: object,
    key: string | symbol,
    descriptor: TypedPropertyDescriptor<TFn>,
): TypedPropertyDescriptor<TFn> | void {
    const originalHandler = descriptor.value!;

    descriptor.value = function (this: any, context: HasResourceINeed, ...inputs: any) {
        context.myResource.logMetricsEtc(...inputs);

        return originalHandler.apply(this, [context, ...inputs]);
    } as TFn;
}

In use, with strictFunctionTypes enabled, this decorator causes a compile error when applied to a method that otherwise looks reasonable:

interface ServiceContext {
    myResource: MyResource;
    otherResource: {
        sendMessageEtc(msg: string): Promise<void>;
    };
}

class MyBusinessClass {
    // This causes a compile error, but shouldn't - the decorator will 
    // work here at runtime.
    @myDecorator
    async foo(context: ServiceContext, x: number): Promise<void> {

    }

    // This example MUST cause a compile error to prevent invalid 
    // usage - there's no resource available in the first arg for the
    // decorator to use.
    @myDecorator
    async bar(y: string): Promise<void> {

    }
}

The undesired compile error looks like this:

Argument of type 'TypedPropertyDescriptor<(context: ServiceContext, x: number) => Promise<void>>' is not assignable to parameter of type 'TypedPropertyDescriptor<(tContext: HasResourceINeed, ...rest: any) => any>'.
  Types of property 'value' are incompatible.
    Type '((context: ServiceContext, x: number) => Promise<void>) | undefined' is not assignable to type '((tContext: HasResourceINeed, ...rest: any) => any) | undefined'.
      Type '(context: ServiceContext, x: number) => Promise<void>' is not assignable to type '(tContext: HasResourceINeed, ...rest: any) => any'.(2345)

I can't reasonably turn off strictFunctionTypes. Is there a way to write the decorator type to accept foo but reject bar?


Solution

  • Presumably you want myDecorator()'s input to be generic in the the type R of the decorated method's first argument and not necessarily in the type Fn of the whole method. This will allow you to accept methods whose first parameter is some subtype of R instead of methods which are subtypes of Fn (which would imply that their argument must be supertypes of R by method parameter contravariance and is not the constraint you want to apply).

    Maybe like this?

    function myDecorator<R extends HasResourceINeed>(
        _target: object,
        key: string | symbol,
        descriptor: TypedPropertyDescriptor<((tContext: R, ...rest: any) => any)>,
    ): TypedPropertyDescriptor<((tContext: R, ...rest: any) => any)> | void {
        const originalHandler = descriptor.value!;
        descriptor.value = function (this: any, context: R, ...inputs: any) {
            context.myResource.logMetricsEtc(...inputs);
            return originalHandler.apply(this, [context, ...inputs]);
        };
    }
    

    That seems to work:

    class MyBusinessClass {
    
        @myDecorator // okay
        async foo(context: ServiceContext, x: number): Promise<void> {
    
        }
    
        @myDecorator // error
        async bar(y: string): Promise<void> {
    
        }
    }
    

    Okay, hope that helps; good luck!

    Playground link to code