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