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?
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.