ndemasie
ndemasie

Reputation: 610

Typescript add method to generic builder type

Trying to cleanup the warnings for buildChainableHTML. TS Playground Link

Is there a way to both:

const buildChainableHTML = <T extends HTMLElement>(el: T): HTMLChainableType<T> // this line

Code

type HTMLChainableType<T extends HTMLElement> = T
    & { chainEventListener: (...args: Parameters<T['addEventListener']>) => HTMLChainableType<T> }

const buildChainableHTML = <T extends HTMLElement>(el: T): HTMLChainableType<T> => {
    el.chainEventListener ??= (...args: Parameters<T['addEventListener']>) => { //  <--  squiggle under '.chainEventListener'
        el.addEventListener.apply(el, args)
        return el
    }
    return el // <-- squiggle under 'return el'
}

const factoryHandler = (event: Event): void => {
    // do something
}

buildChainableHTML(document.querySelector('button')!)
    .chainEventListener('touchstart', factoryHandler)
    .chainEventListener('touchend', factoryHandler)
    .chainEventListener('blur', factoryHandler)
    .chainEventListener('focus', factoryHandler)
    ...
    ...

Upvotes: 1

Views: 239

Answers (1)

jcalz
jcalz

Reputation: 328059

Here's a way to write buildChainableHTML() in as close to a type-safe manner as I can get:

const buildChainableHTML = <T extends HTMLElement>(el: T) => {            
    if ("chainEventListener" in el) return el as typeof ret;
    const ret = Object.assign(el, {
        chainEventListener: (...args: Parameters<T['addEventListener']>) => {
            ret.addEventListener.apply(el, args)
            return ret;
        }
    });
    return ret;
}

The el value of type T that is passed into the function is not known to the compiler to be the type you were originally calling HTMLChainableType<T>. So you can't just start assigning to its chainEventListener property without the compiler complaining about it. The easiest way to circumvent this is to use the Object.assign() method with el as the target; this has the same effect, but the type system doesn't notice.

This also helps us because the output of Object.assign(x, y) is seen by the compiler to be of type typeof x & typeof y, so we automatically get the HTMLChainableType<T> type you want to see. And so we need to use this output anywhere we need that type; we can't use el. So I gave it its own name, ret, and we use that.

Also in order to conditionally assign the chainEventListener property depending on whether or not it already has one, I start with the line if ("chainEventListener" in el) return el as typeof ret;. That type assertion is necessary because there's no guarantee at the type level that an el with an existing chainEventListener member actually has one of the type you're expecting. For all you know someone passes in Object.assign(document.createElement("div"), {chainEventListener: true}) for el. In order to avoid having to name the HTMLChainableType<T> there, I used the typeof type operator on ret. That works because the scope of the typing for ret is the whole function, even though you can't actually the value there. If that didn't work you could have done as never and it wouldn't change the function typing.

But personally I'd suggest just removing that line entirely. I can't imagine it'll matter much if you happen to re-assign the chainEventListener property on an element that already has one, and it makes the function implementation cleaner.


Let's make sure it works as expected. What is the type signature?

/* const buildChainableHTML: <T extends HTMLElement>(el: T) => T & {
    chainEventListener: (...args: Parameters<T["addEventListener"]>) => T & ...;
} */

That ... ellipsis is the quickinfo engine giving up because it's an anonymous recursive type. Personally, I find this unpleasant, since there is no valid way to refer to this type. I'd much rather see a named type like HTMLChainableType<T>. That lets other people talk about it; that lets you generate type declaration files; that lets you inspect the type. But this was part of your requirement so that's what you've got.

Let's keep going:

const chainable = buildChainableHTML(document.querySelector('button')!);
/* const chainable: HTMLButtonElement & {
    chainEventListener: (type: string, listener: EventListenerOrEventListenerObject, 
      options?: boolean | AddEventListenerOptions | undefined) => HTMLButtonElement & ...;
} */

Okay, that looks good also, I think. Let's make sure the chaining works:

const c = chainable
    .chainEventListener('touchstart', factoryHandler)
    .chainEventListener('touchend', factoryHandler)
    .chainEventListener('blur', factoryHandler)
    .chainEventListener('focus', factoryHandler)
    .chainEventListener('mouseover', factoryHandler)
    .chainEventListener('mouseup', factoryHandler)
    .chainEventListener('mousedown', factoryHandler)
    .chainEventListener('scroll', factoryHandler)

/*
const c: HTMLButtonElement & {
    chainEventListener: (type: string, listener: EventListenerOrEventListenerObject, 
      options?: boolean | AddEventListenerOptions | undefined) => HTMLButtonElement & ...;
}
*/

That compiles with no error, and the compiler still sees that c is of the same type as chainable (at least to the extent that we believe ... refers to the same type wherever we see it).

So there you go!

Playground link to code

Upvotes: 1

Related Questions