bind-event-listener icon indicating copy to clipboard operation
bind-event-listener copied to clipboard

Improve TS autocompletion for the type option in `bind`

Open Andarist opened this issue 4 years ago • 7 comments

Before Screenshot 2021-11-19 at 10 24 16

After Screenshot 2021-11-19 at 10 24 34

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.

Andarist avatar Nov 19 '21 09:11 Andarist

This looks great! I'll take a look on Monday ❤️

alexreardon avatar Nov 19 '21 10:11 alexreardon

@devanshj is it possible to get bindAll typed using a single signature in a way that the listener for each item would be typed based on the type property of that item AND the type property would work with autocomplete? I've tried to do this but I've failed, this probably needs to use Self technique and Tuple constraint right? but I kinda cant figure out how to process each item separately

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 number, instead of the concrete index. The autocomplete is also completely broken here.

Andarist avatar Nov 19 '21 18:11 Andarist

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 bindAll type (and hence yours too are so). You see they are making binding.type generic, but it's not! We already have a static union of possible binding.type, namely all event names of the passed target. So only one thing needs to be generic ie the target, that's it.

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 names[^1] 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 MyCustomEvent is a function whose domain ie string (and hence the image too) is infinite, it can be anything, so you can't create a map of it.

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 bind even I would write the former (for simplicity, convenience, maybe performance, etc) but in case of bindAll we would have to write latter because the former approach simply won't work without fixed number of overloads or some complex types which you were attempting.

And now with the latter approach the bindAll's type becomes really straightforward:

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 U which is a function of T, and making T concrete also makes U concrete, then U is not a generic in principle. So it should be made a generic as long as it makes the types simple (like in bind) but not when it makes types complex (like in bindAll).

Thanks for reading my blog, written in a github comment :P

[^1]: Calling event types (click, copy, etc) "name" because "event type" can mean the names or their typescript type (MouseEvent, ClipboardEvent, etc), the code does the same.

devanshj avatar Nov 19 '21 22:11 devanshj

Opened #45.

It turns out that this approach has one downside when supporting untyped events ("supporting" meaning fallbacking to Event for events that were not found on the target as on${eventName}). I'll point out the downside in the PR, but that being said this PR also does not support untyped events (which is a breaking change), so the question is do we want to support it?

If yes, then we'll have to figure out how to make the types and autocomplete work, especially autocomplete because "click" | string becomes string and you'd have to employ tricks like "click" | (string & {}) which only work in some cases. In this case it's probably not possible to make the autocomplete work so.

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 { [$$isUnknownEventType]: true } (which would not interfere with autcomplete) so they could do "foo" as UnknownEventType and then we'd fallback to Event. Edit: Actually we could even support something like "foo" as any or "foo" as never which is much easier.

devanshj avatar Nov 20 '21 04:11 devanshj

This is looking great

alexreardon avatar Dec 03 '21 04:12 alexreardon

I think this PR is a good starting point towards improving the types. Would you be okay if i merged it as is @Andarist?

alexreardon avatar Jan 27 '22 22:01 alexreardon

(I don't have any opinions, just giving a heads up that this PR as it is will be a breaking change)

devanshj avatar Jan 27 '22 22:01 devanshj

@alexreardon I've pushed out improvements for both bind and bindAll. Everything should be autocompletable and you no longer need the huge list of overloads to cover bindAll. It is a breaking change for bindAll though

Andarist avatar Dec 30 '22 14:12 Andarist

Epic!

alexreardon avatar Jan 16 '23 04:01 alexreardon

It is a breaking change for bindAll though

What are the breaking change(s)?

alexreardon avatar Jan 16 '23 04:01 alexreardon

What are the breaking change(s)?

Just that you no longer can pass as many generics to the bindAll as you could in the past. That's basically the required change on the call sites:

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

Andarist avatar Jan 16 '23 08:01 Andarist

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:

  • [ ] Make release notes
  • [ ] Make breaking change

alexreardon avatar Jan 25 '23 08:01 alexreardon

@alexreardon ye, i think it's good to go

Andarist avatar Jan 25 '23 09:01 Andarist

I am aiming to merge and release this early next week 🎉

alexreardon avatar Jan 28 '23 07:01 alexreardon

Thanks so much for the change and the explanations @Andarist

alexreardon avatar Jan 28 '23 07:01 alexreardon