0

Hi everyone.

This is a followup to this question.

interface Works {
  call(): void;
}

interface DoesntWork {
  value: number;
}

interface ShouldWork {
  value: number;

  call(): void;
}

class Handler<T extends { [K in keyof T]: () => any }> {
  public send<P extends Extract<keyof T, string>>(
    methodName: P,
    payload: T[P],
  ) {
    //
  }
}

const handlerA = new Handler<Works>(); // ok
const handlerB = new Handler<DoesntWork>(); // error: ok
const handlerC = new Handler<ShouldWork>(); // error: should work

handlerA.send('call', () => null); // ok
handlerB.send('value', 23); // shouldn't work
handlerC.send('call', () => null); // ok
handlerC.send('value', 42); // 'value' shouldn't work and shouldn't be suggested

Handler should accept ShouldWork because there is at least one function (call) in the interface.

handlerC.send should not suggest value because it is a number and not a function

Here is a playground.

Kleywalker
  • 49
  • 5
  • Possible [this approach](https://tsplay.dev/mA8rBN) is what you want? (The requirement that `DoesntWork` should be *rejected* makes things more difficult, since presumably it should also reject `{}`) If so I could write up an answer explaining; if not, what am I missing? – jcalz Apr 04 '23 at 18:15
  • Yes, `{}` should be rejected as well. This is very nice work! Thank you! Where did you learn this deep generics knowledge? Is it all experience or are there any sources you could recommend? – Kleywalker Apr 04 '23 at 18:46
  • I'll write up an answer when I get a chance. It's essentially experience, I think. A full reading of [the TS Handbook](https://www.typescriptlang.org/docs/handbook/intro.html), [the old language spec](https://github.com/microsoft/TypeScript/blob/3c99d50da5a579d9fa92d02664b1b66d4ff55944/doc/spec-ARCHIVED.md), and [github issues](https://github.com/microsoft/TypeScript/issues) is probably where I know stuff from, but at this point reading issues would take... a long time, if you started from the beginning – jcalz Apr 04 '23 at 18:49

1 Answers1

1

My approach here would be to write an AcceptableHandler<T> utility type that converts T into a version that would be acceptable so that each property that's present is a zero-arg function.

type AcceptableHandler<T> =
  HasProp<PickByValue<T, () => any>, { needsAtLeastOneZeroArgMethod(): any }>

You can read that as: an AcceptableHandler<T> is first PickByValue<T, ()=>any>, meaning that it's just those properties of T which are zero-arg functions (it's like the Pick<T, K> utility type except it picks by the property value and not the property key), and then we apply HasProp<⋯, {needsAtLeastOneZeroArgMethod(): any}>, meaning that it's left alone if the result has even one property, otherwise it's replaced with {needsAtLeastOneZeroArgMethod(): any}, which should hopefully generate error messages in cases where there's not a single acceptable argument for send.

We have to define PickByValue<T, V> and HasProp<T, D> also:

type PickByValue<T, V> =
  { [K in keyof T as T[K] extends V ? K : never]: T[K] }

type HasProp<T, D = never> =
  T extends (keyof T extends never ? never : unknown) ? T : D

The first is a key-remapped type to filter property keys, and the second is a conditional type which checks for absence-of-known-keys.

Armed with that, we can write Handler<T> as follows:

class Handler<T extends AcceptableHandler<T>> {
  public send<P extends keyof AcceptableHandler<T>>(
    methodName: P,
    payload: T[P],
  ) {
    //
  }
}

So first we are constraining T to AcceptableHandler<T>, meaning that we will get errors unless T contains at least one property that can be used as a zero-arg function:

const handlerA = new Handler<Works>(); // okay
const handlerB = new Handler<DoesntWork>(); // error
// ------------------------> ~~~~~~~~~~
// Type 'DoesntWork' does not satisfy the 
// constraint '{ needsAtLeastOneZeroArgMethod(): any; }'.
const handlerC = new Handler<ShouldWork>(); // okay

And then we are constraining send's P type parameter to the keys of AcceptableHandler<T>, meaning that it will only accept keys to zero-arg method properties:

handlerA.send('call', () => null); // okay
handlerB.send('value', 23); // error
// ---------> ~~~~~~~
// Argument of type '"value"' is not assignable to 
// parameter of type '"needsAtLeastOneZeroArgMethod"'.
handlerC.send('call', () => null); // okay
handlerC.send('value', 42); // error
// ---------> ~~~~~~~
// Argument of type '"value"' is not assignable to
// parameter of type '"call"'.

Note that the error on handlerB is a little weird in that it's expecting "value" to be "needsAtLeastOneZeroArgMethod", which would surely do bad things at runtime if you actually used it, but handlerB itself is already an error at its creation time, so any errors you get afterward are less important. If you resolve the original error at new Handler<DoesntWork>(), then the error at send() should go away or be replaced with a more informative error.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for the detailed answer! I'm not sure if it would be a new question or an addition to your answer: Would it change much if we assume that not only zero arg functions are allowed but any kind of function? – Kleywalker Apr 04 '23 at 19:34
  • The question had `() => any` in the text so I used the same requirement. If it's "any kind of function" then you can just replace it with `(...args: any) => any` or maybe `Function`. But if you didn't care about that then the question probably should have been written differently; I hope we don't need to expand the scope here to include it – jcalz Apr 04 '23 at 19:37
  • No, thats fine. It's just an additional thought. Thanks again! – Kleywalker Apr 04 '23 at 19:43