-
Notifications
You must be signed in to change notification settings - Fork 8
Improve TS autocompletion for the type option in bind
#44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve TS autocompletion for the type option in bind
#44
Conversation
This looks great! I'll take a look on Monday ❤️ |
@devanshj is it possible to get My latest, poor, stab at this can be found here. I can't make the Bindings to be inferred as tuple, so the key in the mapped type gets served to me as |
Yep it's possible but you need none of that, not even the tuple inference trick. The thing is these existing types are unnecessarily complex, in particular the I'm about to rewrite the types and send a PR, here's a preview. The key here is to realize the events and their names1 are not existentially generic, that is to say (for a given target) we have a fixed set of values, we can represent it like so... type HtmlEvent<N extends "click" | "copy" | ...> =
{ click: MouseEvent
, copy: ClipboardEvent
, ...
}[N] It would have been existentially generic if were something like this... type MyCustomEvent<N extends string> =
N extends `on${infer X}Changed`
? { type: N } & { [_ in X]: number }
: never ...here So your bind type which loosely is... declare const bind:
<T extends EventTarget, N extends EventName<T>>
(target: T, binding: { type: N, listener: (e: Event<T, N>) => void }) =>
() => void ...can be written as... declare const bind:
<T extends EventTarget>
( target: T
, binding:
EventName<T> extends infer N ? N extends unknown ?
{ type: N, listener: (e: Event<T, N>) => void }
: never : never
) =>
() => void Now for And now with the latter approach the declare const bindAll:
<T extends EventTarget>
( target: T
, bindings:
( EventName<T> extends infer N ? N extends unknown ?
{ type: N, listener: (e: Event<T, N>) => void }
: never : never
)[]
) =>
() => void So if I were to give a takeaway (because reading so much must be rewarded :P), if you have a generic Thanks for reading my blog, written in a github comment :P Footnotes
|
Opened #45. It turns out that this approach has one downside when supporting untyped events ("supporting" meaning fallbacking to If yes, then we'll have to figure out how to make the types and autocomplete work, especially autocomplete because If no, then my approach would work and the types become simple. And remember no means that there will be no typescript support, the runtime would obviously work, so they'd have to make assertions. Or we could provide them with an opaque type |
This is looking great |
I think this PR is a good starting point towards improving the types. Would you be okay if i merged it as is @Andarist? |
(I don't have any opinions, just giving a heads up that this PR as it is will be a breaking change) |
src/bind.ts
Outdated
|
||
export function bind<Target extends EventTarget, Type extends string>( | ||
export function bind<Target extends EventTarget, Type extends PossibleEventType<keyof Target>>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is magic 👏
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A true magic was probably here 😅 : https://github.com/alexreardon/bind-event-listener/pull/45/files
src/types.ts
Outdated
export type PossibleEventType<K> = K extends any | ||
? K extends `on${infer Type}` | ||
? Type | ||
: never |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what would happen for an event that did not have a on* property (eg DOMContentLoaded
). Would that be allowed, or would it be an error now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it be string
rather than never
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally we get auto complete + inference goodness. But when the event has no on*
it just gets an Event
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it be string rather than never?
This might turn off the autocomplete etc as we basically distribute a union of literal strings here and narrow it down to a union of literal strings (the operation here acts as a .filter()
). We can't remap this union to smth like 'foo' | string
because that's basically string
(types reduce themselves to a common denominator).
But when the event has no on* it just gets an Event
Could you give me a practical example of when this would be useful?
what would happen for an event that did not have a on* property (eg DOMContentLoaded). Would that be allowed, or would it be an error now?
Most likely an error now - but since this special case is known upfront I could try to introduce support for it.
There is also a chance that maybe this could be actually implemented with an additional overload. This is how window.addEventListener
is implemented:
addEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
And it comes with autocomplete and allows u to use a random string.
As mentioned elsewhere - I'm not a huge fan of overloads as they don't play that well with some TS features (for instance, when using helpers like ReturnType
or a conditional type with infer
then only the very last overload is taken into a consideration and all the previous ones are ignored). This could work OK in this context here though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fwiw I liked #10 that handled all event targets. And alternatively you can also have the users pass additional event maps via module augmentation.
(I guess eventually I'll probably make an events package in sthir that would only have types required to deal with event emitters, it's a common problem eg even RxJS's fromEvent
could leverage it)
ad7ea9a
to
818cc3d
Compare
@alexreardon I've pushed out improvements for both |
Epic! |
What are the breaking change(s)? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking great
TTarget, | ||
InferEvent< | ||
TTarget, | ||
// `& string` "cast" is not needed since TS 4.7 (but the repo is using TS 4.6 atm) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can update to TS@latest to remove this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the question is - what should be the minimum TS requirement for the end users of the package?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are doing a breaking change anyway for the type change, I am thinking we can move to a relatively high TS target. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
4.7 has been released smth like 8 months ago. DefinitelyTyped officially supports the last 2 years of TS releases - that doesn't necessarily mean that you have to have the same policy though. It's not exactly like you have millions of downloads of this package - so I think that it's reasonable to prioritize your own use cases, ease of maintenance, etc.
That being said - this cast here is completely harmless. I just put this comment here to give a little bit of context behind this cast and also to leave the information on when it could be removed in the future (when you drop support for TS 4.6).
The more important change for this lib is coming in TS 5.0 - but the difference between the version of the code in this PR and the potential version of the code that would be compatible with TS 5.0 is also really subtle and you don't even have to follow up on that one immediately either.
However, it still might be worthwhile to act on it when TS 5.0 gets released (March 14th). That would remove the autocomplete capabilities for <5.0 users but the types would become slightly better (conceptually and all). So this is kinda up to you - I think that it's a fair game that autocompletion would behave differently between those TS versions. The code/types themselves would still work - the only difference would be the DX in the IDE.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To confirm my understanding:
- The current version would work on TS 4.6+ (and 5.0)
- After 5.0 is released we could do some internal cleanup of the code and still have good auto complete
Questions:
- After 5.0 could we keep the currently proposed change and maintain good auto-complete for everybody? (Could eventually move to 5.0 later)
cc @Andarist
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current version would work on TS 4.6+ (and 5.0)
Yes.
After 5.0 is released we could do some internal cleanup of the code and still have good auto complete
Yes, but if we make that cleanup then TS 4.x would lose autocomplete
After 5.0 could we keep the currently proposed change and maintain good auto-complete for everybody? (Could eventually move to 5.0 later)
Yes, we don't have to do this cleanup immediately. We are free to leave it as is for a foreseeable future. We could also introduce said the cleanup, copy the current types into a separate file, and use typesVersions
to point TS 4.x users to that other file. This arguably complicated the setup and stuff - so it's up to you if you would be comfortable with this or not
const unbinds: UnbindFn[] = bindings.map((original: Binding) => { | ||
const binding: Binding = getBinding(original, sharedOptions); | ||
const unbinds: UnbindFn[] = bindings.map((original) => { | ||
const binding: Binding = getBinding(original as never, sharedOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Andarist can you please add a comment to the code explaining why as never
is needed? I suspect that would be helpful information long term
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's just a "fancy" as any
- I can just change it to as any
to avoid confusing people.
as never
works because never
is assignable to just everything (but nothing, except never
, is assignable to it). Since the cast happens in the argument position - it truly doesn't matter which one we use here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can go for whichever you think is best (and most maintainable)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no real difference between both here. When types flow through layers you might end up with the casted type in the return type or something, so then as never
might be a little bit safer than as any
- because you won't accidentally start accessing properties on any
type (as that would be never
and you wouldn't be able to access any properties on it).
I decided to keep as never
- as a slightly safer one (although not really at this particular call site at this specific point in time). If you'd prefer as any
- I can change this any time
Just that you no longer can pass as many generics to the - bindAll<HTMLInputElement, 'focus', 'blur'>(/* ... */)
+ bindAll<HTMLInputElement, ['focus', 'blur']>(/* ... */) I don't think this is a big deal though since the inference improvements are quite nice here and both the old and the proposed types were targeting mostly inference-based scenarios. But if somebody was supplying those type arguments manually then it's a breaking change for them. |
650349e
to
f331ec1
Compare
src/types.ts
Outdated
export type Binding<Target extends EventTarget = EventTarget, EventName extends string = string> = { | ||
type: EventName; | ||
listener: Listener<Target, EventName>; | ||
export interface Binding<TTarget extends EventTarget = EventTarget, TType extends string = string> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why interface
s an not type
s? (Personally, I prefer the ergonomics of type
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's rather an accidental change here - I was juggling the code a lot through my couple of attempts at this PR 😅 I reverted the change.
Overall there are not a lot of differences, but sometimes interfaces still "display" better in error tooltips, quick info etc. I don't know what the exact rules are but interfaces are always referenced by name there whereas type aliases are sometimes referenced by name and sometimes they are inlined.
Extending interfaces comes with some extra checks in TS, whereas intersecting aliases is always allowed (but might lead to accidental mistakes): TS playground
I also don't know the exact rules behind this but when we check is IA<T1>
is assignable to IA<T2>
we only need to check if T1
is assignable to T2
(or the other way around if the given type is a contravariant one). The same might happen with type aliases (I'm not sure?) but they definitely sometimes use a full structural comparison - in other words, if the type is huge but it's type arguments are small then with interfaces such a comparison is guaranteed to be fast whereas with type aliases it sometimes might not be as type aliases might be sometimes compared property by property.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obligatory note: it usually doesn't matter at all - don't stress it out 😅
Sorry, it's been a busy few days! I am keen to push this forward. Are you happy with where this PR is at @Andarist? Post-merge things to do:
|
3978b7c
to
c2ee043
Compare
@alexreardon ye, i think it's good to go |
|
||
export type InferEventType<TTarget> = TTarget extends { | ||
addEventListener(type: infer P, ...args: any): void; | ||
addEventListener(type: infer P2, ...args: any): void; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is addEventListener(type: infer P2, ...args: any): void;
(and P2
) for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added comments to the source code that explain it
I am aiming to merge and release this early next week 🎉 |
Thanks so much for the change and the explanations @Andarist |
export type Listener<Target extends EventTarget, EventName extends string> = | ||
| ListenerObject<GetEventType<Target, EventName>> | ||
| { (this: Target, e: GetEventType<Target, EventName>): void }; | ||
export type Listener<TTarget extends EventTarget, TEvent extends Event> = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My finger is hovering over the merge button and I am thinking through release notes.
What is the rationale for changing from EventName extends string
to TEvent extends Event
for Listener
? Before Listener
did more heavy lifting behind the scenes.
- Listener<{} as HTMLElement, 'click'>
+ Listener<{} as HTMLElement, InferEvent<{} as HTMLElement, 'click'>>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not 100% sure right now. I was working on this for weeks and all of the minor details are already lost for me. This could start as an experiment that stayed here or it could be needed.
Notice that I am actually passing arguments to it that look slightly different from each other, so there is a chance that unifying those "call sites" was not possible. So maybe I just unwinded a part of the "abstraction" to allow for more flexible type arguments. I'm not sure though.
The change makes sense from the types PoV/design - this doesn't mean that the previous version was not OK though. It all boils down to what kind of API you want to give to your consumers. The returned type is exactly the same - the only difference is in the arguments (and potentially in inference capabilities etc).
The previous version was more on the verge of a type helper, with more type gymnastics in it (and such gymnastics can sometimes have a negative impact on inference because TS might have a harder time understanding this, but again - I'm not sure if that was the case here).
I'm afraid that I probably don't have time to tinker with this more to investigate if this change could be rolled back. Note that what is internally used doesn't have to be exposed directly. So we could rename the existing Listener
to something else (InternalListener
?) and build the "old" Listener
from the available pieces.
Binding<Target, Type6>, | ||
], | ||
sharedOptions?: boolean | AddEventListenerOptions, | ||
): UnbindFn; | ||
export function bindAll< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To confirm my understanding, the signature of bindAll
has changed:
- bindAll<{} as HTMLElement, 'click', 'mousedown'>
+ bindAll<{} as HTMLElement, ['click', 'mousedown']>
But if you are using inference then nothing has changed. Is that right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's the hope at least.
Before

After

Can be tested on this TS playground
The same thing could be done for
bindAll
but the code for this function is way more complex and I didn't have time to dive into this right now. I would also recommend dropping overloads from there and handle everything in a single signature.