Consider the following typescript code:
interface IHookable<THooks extends object> {
trigger<THook extends keyof THooks>(hook: THook, parameters: THooks[THook]): void;
}
interface MainClass<TExtendedHooks extends object = {}> extends IHookable<{
'firstHook': [MainClass],
'secondHook': [MainClass]
} & TExtendedHooks> {}
class MainClass<TExtendedHooks extends object = {}> {
innerFunction() {
this.trigger('firstHook', [this]);
}
}
/** The trigger function will be added to the MainClass with a mixin here, but it is ommited for brevity */
class ExtendingClass extends MainClass<{
'thirdHook': [ExtendingClass]
}> {
otherInnerFunction() {
this.trigger('firstHook', [this]);
this.trigger('thirdHook', [this]);
}
}
What I am trying to achieve, is to create a class which exposes some hooks, and create an extending class, which may expose some other hooks as well, without touching the original hooks. The code itself works as expected, but typescript is complaining in the base class, that:
Argument of type '[this]' is not assignable to parameter of type '[MainClass<{}>] & TExtendedHooks["firstHook"]'.
Type '[this]' is not assignable to type 'TExtendedHooks["firstHook"]'.(2345)
I presume I am getting this error, because typescript can not be sure, that my TExtendedHooks generic parameter, doesn't overwrite or extend one of the original, predefined hooks I am using. How could I solve this typescript error, so that the trigger functions for hooks that are introduced in the main class, can be callable in the main class code, without a given error?
Here's a playground link as well.
Side notes:
The whole IHookable interface was created because we have a bunch of classes that should be able to trigger events, and they all do it the same way. For this, we created a "trait", and each of those classes should mixin the given trait. The interface is there, so that when someone tries to use the functions implemented by the trait, he gets intellisense and autocompletion support. For example:
interface SomeClass extends IHookable<{
'specificHookForSomeClass': [string]
}>{}
class SomeClass {...}
interface SomeTotallyDifferentClass extends IHookable<{
'specificHookForSomeTotallyDifferentClass': [number]
}> {}
interface SomeTotallyDifferentClass {...}
/** and consider a bunch of other classes in the above manner - with the trait, we won't be implementing the logic for the "trigger" function in each one of them, we just mixin the traits - by specifying the interface, when someone tries to call the trigger on a class that implements this interface, receives intellisense support and type validation, like below **/
const someTotallyDifferentClassInstance = new SomeTotallyDifferentClass();
someTotallyDifferentClassInstance.trigger('specificHookForSomeTotallyDifferentClass', ['incorrect string parameter']); // I am getting an error here, this trigger function would need a "number", and not a string
This was working greatly until now, but we would need to extend a class, let's say SomeTotallyDifferentClass and in the extension, we would like to give another option for the trigger function, that is callable on those instances. So what we tried, is give a generic variable to SomeTotallyDifferentClass, so when it is being extended, this generic may be supplied to extend the "hooks" for the class. It is working code wise, but typescript starts complaining, if I try to call the "trigger" function, from inside the class, outside the class it still works, and works as expected. I've also edited my playground link, to show an example of this.
Manipulation of generic types is tricky; the compiler is not always able to make the sorts of "obvious" connections that a human being can. If a type is not generic then the compiler can follow some reduction rules to compare different types. But for generics it often just gives up. Either it gives up by saying "I don't know what's assignable here so I'm going to reject everything", or by saying "I'm just going to replace the unknown generic type with its constraint and hope that the substitution is good enough". So complex types that depend on generic type parameters can generate both false positives and false negatives from the compiler.
It's sometimes possible to refactor things to turn an error situation (false or otherwise) into a non-error situation (again, possibly erroneously), but it's more of an art than a science and I don't know if I could give authoritative reasoning for exactly why one version breaks and another succeeds.
One way to convince the compiler that this.trigger('firstHook', [this]) is acceptable inside MainClass<T> no matter what T is, is to constrain T so that it cannot introduce a firstHook property that's incompatible. Like this:
interface MainHook {
'firstHook': [MainClass],
'secondHook': [MainClass],
}
interface MainClass<T extends Partial<MainHook> & object = {}>
extends IHookable<MainHook & T> { }
class MainClass<T extends Partial<MainHook> & object = {}> {
innerFunction() {
this.trigger('firstHook', [this]); // okay
}
}
Since T is assignable to Partial<MainHook>, then MainHook & T will have a firstHook property assignable to ([MainClass] | undefined) & [MainClass], or [MainClass]. And luckily the compiler is able to follow this logic to allow things to compile.
(Note the & object is there to prevent weak type detection, which doesn't help us.)
So great, that works. But is that actually type safe? Uh, I don't think so. Given that T["firstHook"] can be narrower than [MainClass], it's possible that IHookable<MainHook & T>'s trigger() method should reject an arbitrary [MainClass] input. It looks like the compiler just compares against T's constraint when checking. So it's possible although unlikely for someone to craft a type that does bad things.
Why didn't your version work? Again, not sure. It presumably checked against T["firstHook"] and had no idea what it might be, and gave up in the "reject" direction instead of the "accept" direction. 🤷♂️
Playground link
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With