5

I'm defining a _wrap method on a base class, that modifies methods on derived classes.

class Base {
  //this is the signature I'm working out
  _wrap(key: unknown) {
      const fn = this[key]
      //I want typeof fn to be "Function"
      //which is not callable
      fn()
  }
}

class Derived extends Base {
    constructor() {
        super()
        //I want _wrap1 to require "isAFunction1 | isAFunction2"
        super._wrap("isAFunction1")
    }

    isAFunction1() { }
    isAFunction2() { }
    notAFunction = "some value"
}

I'm struggling to write the type signature of this method such that it:

  1. Prevents calling _wrap from derived classes with keys that don't correspond to functions on the derived class
  2. Allows me to use this[key] inside _wrap as the appropriate type without casting

I can use _wrap(key: keyof typeof this) to achieve #1, but not #2.

I feel like this related question should work. I don't understand why, but it doesn't meet either requirement - see the code playground here.

type KeysMatching<T, V> = {[K in keyof T]-?: T[K] extends V ? K : never}[keyof T];

class Base {
  _wrap(key: KeysMatching<typeof this, Function>) {
      const fn = this[key]
      //I expect typeof fn to be "Function", but it's not, it's this[KeysMatching<this, Function>], 
      //which is not callable
      fn()
  }
}

class Derived extends Base {
    constructor() {
        super()
        //I expect _wrap1 to expect "isAFunction1 | isAFunction2", but it's not, it's
        //this[KeysMatching<this, Function>], which doesn't match string
        this._wrap("isAFunction1")
    }
    isAFunction1() { }
    isAFunction2() { }
    notAFunction = "some value"
}

Is there a way to write this signature to achieve both these goals?

(Note: This is probably not ideal code in the first place. I'm annotating an existing library and not making code changes.)

Joel
  • 2,217
  • 5
  • 34
  • 45
  • 1
    Please include your [mre] as plain text in the post itself; an external IDE link is a great supplement but it doesn’t take the place of having the relevant code accessible directly in SO. – jcalz Apr 26 '22 at 16:30
  • @jcalz I wasn't sure, edited – Joel Apr 26 '22 at 16:55
  • Does [this approach](https://tsplay.dev/mq5gqm) meet your needs? I'm not sure why you are trying to call `super._wrap1("isAFunction")` instead of (say) `this._wrap("isAFunction1")`, since `super` isn't known to have those subclass keys, and since `"isAFunction"` is not a key of anything in your example. And I'm a bit wary of you calling methods without binding them or caring about parameters. Maybe you can't modify the code in your actual use case, but for example code it's best not to distract focus from the issue at hand (i.e., the call signature for `_wrap1`). – jcalz Apr 26 '22 at 18:23
  • @jcalz Fixed the examples a bit, thanks. I agree this is not a good approach generally, just trying to annotate what's already there before reworking. This approach looks good, thanks! If you'd like to add it as an answer, I'll mark it accepted. – Joel Apr 26 '22 at 21:05

1 Answers1

2

The problem with using KeysMatching<T, V> is that the compiler is not clever enough to see that T[KeysMatching<T, V>] will be assignable to V. It's a type function involving conditional types on generic type parameters, which the compiler mostly just defers and sees as opaque. See microsoft/TypeScript#30728 for more information.

On the other hand, the compiler is smart enough to understand that Record<K, V>[K] (equivalently {[P in K]: V}[K]) is assignable to V... that's a generic indexed access into a mapped type, with no conditional types getting in the way.

So this means you could try to constrain this to Record<K, () => void> for generic keylike K corresponding to the type of key, instead of constraining the type key itself.

Note that I'm using the () => void function type expression instead of the Function type; the former is specifically a function that can be (relatively) safely called with zero arguments, while the latter is an untyped function call which doesn't guard against misuse of parameter types. If fn is (x: string) => x.toUpperCase(), you really don't want to call fn(). If you check against Record, you'll get no compiler warning and the issue will only be seen as a runtime error. If you check against () => void, the compiler will complain that (x: string) => string is not assignable to () => void.


Anyway, that call signature might look like:

class Base {
  _wrap1<K extends PropertyKey>(this: this & Record<K, () => void>, key: K) {
    const fn = this[key].bind(this) // <-- bound
    fn()
  }
}

I'm using a this parameter to tell the compiler that you can only call foo._wrap1() on an object foo assignable to this & Record<K, ()=>void> (the intersection of: the this type corresponding to the type of the current derived subclass; and Record<K, ()=>void>.

Oh, another aside. Your example code just calls fn() without first binding it to this. That could be a big problem for any method implementation that dereferences this. And unfortunately TypeScript won't catch these errors for you easily, see microsoft/TypeScript#7968 for more information. So I wrote this[key].bind(this) to avoid runtime errors.


Let's test it:

class Derived extends Base {
  constructor() {
    super()
    this._wrap1("isAFunction1") // okay
    this._wrap1("isAFunction2") // okay
    this._wrap1("notAFunction") // error!
    //~~ <-- types of property 'notAFunction' are incompatible.
  }
  isAFunction1() { console.log(this.notAFunction + " says hello") }
  isAFunction2() { console.log(this.notAFunction + " says goodbye") }
  notAFunction = "some value"
}

new Derived() 
// some value says hello
// some value says goodbye
//  this[key].bind is not a function

Looks good. Inside Derived the compiler accepts this._wrap1("isAFunction1") and this._wrap1("isAFunction2"), but rejects this._wrap1("notAFunction") which would lead to a runtime error. The location of the compiler warning message you get is not the best, since it interprets the problem as being with this and not with "notAFunction". Not sure how much that matters, but changing that isn't simple (my brief attempts kept bringing back your original answers).

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360