typescripttypescript-genericstypescript-decoratortypescript-conditional-types

How can I infer the return type of a decorated function for use in decorator parameters?


I have a decorator that can run checks before executing the decorated method. If the decorated method returns a value, I want to force the user to provide a return value in the decorators options parameter that will be returned if one of the checks fails.

To do this, I would like to infer the return type of the decorated method and use it in a conditional type that requires the options parameter to include the property returnValue with the correct type if the method returns something other than void | undefined.

This is the closest I got:

function Checked<R>(
        options?: [R] extends [void | undefined] ?
            Options : // Derocated method doesn't return a value, so no returnValue in options
            Options & OptionsWithReturnValue<R> // Derocated method returns a  value, so require returnValue in options
    ) {
    return function decorator<This extends any, Args extends any[]>(
        originalMethod: (this: This, ...args: Args) => R,
        context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>,
    ): (this: This, ...args: Args) => R {
        if (options) {
            return function replacement(this: This, ...args: Args): R {
                if (options.checkPermissions && !checkPermissions()) {
                    return (options as OptionsWithReturnValue<R>).returnValue;
                }
                if (options.checkIsEnabled && !checkIsEnabled()) {
                    return (options as OptionsWithReturnValue<R>).returnValue;
                }

                return originalMethod.call(this, ...args);
            }
        }

        return originalMethod;
    }
}

interface Options {
    checkPermissions?: boolean;
    checkIsEnabled?: boolean;
}

interface OptionsWithReturnValue<R> {
    returnValue: R;
}

// Dummy method for demonstration purposes
function checkPermissions() {
    return Math.random() > 0.5;
}

// Dummy method for demonstration purposes
function checkIsEnabled() {
    return Math.random() > 0.5;
}

class Test {
    @Checked({ checkPermissions: true }) // Errors correctly
    public returnsSomehing() {
        return 'Hello World';
    }

    @Checked({ checkPermissions: true, returnValue: false }) // Errors correctly
    public returnsNothing() {
        console.log('Hi');
    }

    @Checked({ checkPermissions: true, returnValue: false }) // Should not be an error, returnValue should be boolean
    public returnsBooleans() {
        if (Math.random() > 0.5) {
            return true;
        }
        return false;
    }

    @Checked({ checkPermissions: true, returnValue: 0 }) // Correct
    public returnsNumbers() {
        return Math.random();
    }
    
    @Checked({ checkPermissions: true, returnValue: 'no' }) // Should not be an error, returnValue should be string
    public returnsStrings() {
        if (Math.random() > 0.5) {
            return 'yes';
        }
        return 'no';
    }  
}

Playground link with the same code

It sort of works for some cases, but seems to get it backwards. Instead of inferring R from the decorated methods return type, it seems to infer it from the options parameter, if it contains the returnValue property. For example in the returnsBoolean() case, R is inferred to be false, but it should be boolean.

I realize I could just set R explicitly, like @Checked<boolean>({ checkPermissions: true, returnValue: false }), but I don't want that. I want the decorator to be as easy and safe to use as possible.

How can I get the decorator to infer R correctly from the return type of the decorated method?


Solution

  • The type of Checked is

    declare function Checked<R>(
      options?: [R] extends [void | undefined] ? Options : Options & OptionsWithReturnValue<R>
    ): <This extends unknown, Args extends any[]>(
      originalMethod: (this: This, ...args: Args) => R, 
      context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>
    ) => (this: This, ...args: Args) => R
    

    and when you call it as a decorator, there are multiple inference sites for the generic type parameter R. TypeScript sometimes infers R from the return type of originalMethod as desired, but other times it infers R from the type of the options argument, which you don't want.

    The easiest way to prevent options from being used as an inference site is to use the NoInfer<T> utility type there:

    declare function Checked<R>(
        options?: NoInfer<
            [R] extends [void | undefined] ? Options : Options & OptionsWithReturnValue<R>
        >
    ): <This extends unknown, Args extends any[]>(
        originalMethod: (this: This, ...args: Args) => R,
        context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>
    ) => (this: This, ...args: Args) => R
    interface Options {
        checkPermissions?: boolean;
        checkIsEnabled?: boolean;
    }
    

    Now when you call Checked as a decorator, TypeScript simply cannot infer R based on the options argument, and it will attempt to infer it from other inference sites instead. That fixes the problems you're having:

    // R inferred as string
    @Checked({ checkPermissions: true }) // errors as desired
    public returnsSomehing() {
        return 'Hello World';
    }
    
    // R inferred as void
    @Checked({ checkPermissions: true, returnValue: false }) // errors as desired
    public returnsNothing() {
        console.log('Hi');
    }
    
    // R inferred as boolean
    @Checked({ checkPermissions: true, returnValue: false }) // compiles as desired
    public returnsBooleans() {
        if (Math.random() > 0.5) {
            return true;
        }
        return false;
    }
    
    // R inferred as number
    @Checked({ checkPermissions: true, returnValue: 0 }) // compiles as desired
    public returnsNumbers() {
        return Math.random();
    }
    
    // R inferred as "yes" | "no"
    @Checked({ checkPermissions: true, returnValue: 'no' }) // compiles as desired
    public returnsStrings() {
        if (Math.random() > 0.5) {
            return 'yes';
        }
        return 'no';
    }
    

    Looks good! Note that the last one would still be an error if you changed returnValue to, say, 'maybe'

    // R inferred as "yes" | "no"
    @Checked({ checkPermissions: true, returnValue: 'maybe' }) // error
    public returnsStrings2() {
        if (Math.random() > 0.5) {
            return 'yes';
        }
        return 'no';
    }
    

    but that's probably out of scope here, since that has to do with widening/literal inference, and if you really cared you could annotate the function's return type as string to avoid it.

    Playground link to code