typescriptgenerics

Enforce type argument of a decorator to be a subset of the method return type


I'm trying to enforce the type argument of a decorator to be a subset of the decorated methods return type but I'm not able to solve this:

function foo<U>() {
    return function<T extends U>(target: any, propertyKey: any, descriptor: TypedPropertyDescriptor<() => T>){};
}

class Bar { 
    baz: number = 0;
}

class Baz {
    @foo<Bar>()        // should be valid but it fails with 1241
    @foo<null>()       // should be valid but it fails with 1241
    @foo<Bar | null>() // should be valid and it is valid
    @foo<number>()     // should fail and it does fail
    qux(): Bar | null {
        return new Bar();
    }
}

I don't want to change the return type, and I guess this is my problem here. I just want to have a check for U being a subset of T.

EDIT: I'm using experimentalDecorators, see https://tsplay.dev/w28pYm.


Solution

  • The problem is that you've erroneously written the constraint T extends U, meaning that T must be a subtype of U. But you really want it to be the other way around, where T is a supertype of U. It would be nice if you could write T super U instead, but TypeScript doesn't support such lower bound constraints. There's a longstanding open feature request for that at microsoft/TypeScript#14520 but so far it's not part of the language.

    Luckily you can mostly emulate such a constraint, from the caller's point of view at least. The idea is to use a conditional type that swaps T extends U (which isn't what you want) to U extends T (which is what you want). Like this:

    function foo<U>() {
      return function <T extends ([U] extends [T] ? unknown : never)>(
        target: any, propertyKey: any, descriptor: TypedPropertyDescriptor<() => T>) {
    
      };
    }
    

    Here's how it works. If U extends T is true, then the check [U] extends [T] succeeds, and the constraint on T becomes T extends unknown which is always true because unknown is the TypeScript top type). But if U extends T is false, then the check [U] extends [T] fails, and the constraint on T becomes T extends never, which is (almost) always false (unless T is never but that's unlikely to happen accidentally) because never is the TypeScript bottom type.

    Note that the conditional type is [U] extends [T] ? unknown : never and not U extends T ? unknown : never because the latter would be a distributive conditional type and that's not what you want, since if U is a union type you don't want to check each member of U against T. Wrapping both sides of the check with the one-tuple [+] turns off distributivity while preserving the sense of the check.

    And now you get the behavior you expect:

    class Baz {
      @foo<Bar>()        // okay
      @foo<null>()       // okay
      @foo<Bar | null>() // okay
      @foo<number>()     // error
      qux(): Bar | null {
        return new Bar();
      }
    }
    

    Playground link to code