Skip to content

feat: generate strict types for addEventListener and removeEventListener for components #4908

@m-thompson-code

Description

@m-thompson-code

Prerequisites

Describe the Feature Request

This feature would give type safety when using addEventListener or removeEventListener. This would improve the developer experience of TypeScript developers.

This would also make consuming Stencil components in an Angular project with or without using the Angular build target manageable with strictTemplates flag set to true.

Describe the Use Case

This feature would allow developers to use Stencil components in a TypeScript project where using addEventListener and removeEventListener would use proper types and would eliminate the need always to use type assertion, create custom types, or overloads to each component's html element type and define them ourselves in HTMLElementTagNameMap.

Consider the following:

import { Component, Event, EventEmitter, Prop, h } from '@stencil/core';

@Component(/* ... */)
export class MyComponent {
  // Add an event `example`:
  @Event() example!: EventEmitter<number>;
 // ...
}
// HTMLMyComponentElement
const myComponent = document.createElement('my-component');

myComponent.addEventListener('example', (event) => {
  // Property 'detail' does not exist on type 'Event'.ts(2339)
  console.log(event.detail);
});
Screenshot 2023-10-08 at 6 35 11 PM

This type for the event callback shouldn't be Event but instead MyComponentCustomEvent<number>.

This issue makes working with Stencil in an Angular project very difficult since there are less options and is unclear to influence this behavior in an Angular template:

Screenshot 2023-10-08 at 7 17 51 PM

Describe Preferred Solution

Stencil should provide types to Stencil component's html elements such that using addEventListener and removeEventListener have specific types instead of just Event, which is typically wrong since most events are likely a CustomEvent of some kind.

Since there are no overloads for each component's html element, they all fallback to HTMLElement for addEventListener and removeEventListener:

interface HTMLElement {
    // ...
    addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

If Stencil generated these overloads, there wouldn't be any extra steps needed to use addEventListener and removeEventListener:

/** demo/src/components.d.ts */

// ...

export interface MyComponentCustomEvent<T> extends CustomEvent<T> {
    detail: T;
    target: HTMLMyComponentElement;
}
declare global {
    interface HTMLMyComponentElementEventMap {
        example: number;
    }
    interface HTMLMyComponentElement extends Components.MyComponent, HTMLStencilElement {
        addEventListener<K extends keyof HTMLMyComponentElementEventMap>(type: K, listener: (this: HTMLMyComponentElement, ev: MyComponentCustomEvent<HTMLMyComponentElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
        addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
        removeEventListener<K extends keyof HTMLMyComponentElementEventMap>(type: K, listener: (this: HTMLMyComponentElement, ev: MyComponentCustomEvent<HTMLMyComponentElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
        removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
    }
    var HTMLMyComponentElement: {
        prototype: HTMLMyComponentElement;
        new (): HTMLMyComponentElement;
    };
    interface HTMLElementTagNameMap {
        "my-component": HTMLMyComponentElement;
    }
}

This would make dealing with events a lot easier in general:
Screenshot 2023-10-08 at 7 44 38 PM

And especially Angular:
Screenshot 2023-10-08 at 7 46 18 PM

Describe Alternatives

Creating a custom build target to create types that overload addEventListener and removeEventListener for each Stencil component's html element type. Or add types with overloads somewhere in your project when consuming your Stencil components. Or modifying your generated components.d.ts through some other kind of automation.

Example:

export interface MyComponentCustomEvent<T> extends CustomEvent<T> {
    detail: T;
    target: HTMLMyComponentElement;
}
declare global {
    interface HTMLMyComponentElementEventMap {
        example: number;
    }
    interface HTMLMyComponentElement extends Components.MyComponent, HTMLStencilElement {
        addEventListener<K extends keyof HTMLMyComponentElementEventMap>(type: K, listener: (this: HTMLMyComponentElement, ev: MyComponentCustomEvent<HTMLMyComponentElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
        addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
        removeEventListener<K extends keyof HTMLMyComponentElementEventMap>(type: K, listener: (this: HTMLMyComponentElement, ev: MyComponentCustomEvent<HTMLMyComponentElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
        removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
    }
    var HTMLMyComponentElement: {
        prototype: HTMLMyComponentElement;
        new (): HTMLMyComponentElement;
    };
    interface HTMLElementTagNameMap {
        "my-component": HTMLMyComponentElement;
    }
}

Another workaround is to use Another workaround to this issue is this custom output target I made: https://github.com/m-thompson-code/event-listener-types-output-target#event-listener-types-output-target to automate creating event listener types.

Related Code

  1. Generate a stencil component library:
npm init stencil
cd demo

Choose component --> Project name: demo --> Confirm Y

  1. Add an event named example with type EventEmitter<number> to MyComponent found at demo/src/components/my-component/my-component.tsx:
// Add `Event` to import
import { Component, Event, EventEmitter, Prop, h } from '@stencil/core';
// ...
@Component(/* ... */)
export class MyComponent {
  // Add an event `example`:
  @Event() example!: EventEmitter<number>;
 // ...
}
  1. Build stencil library:
npm run build
  1. Add the following to any TypeScript find such as index.ts found at demo/src/index.ts:
// HTMLMyComponentElement
const myComponent = document.createElement('my-component');

// Add event listener
myComponent.addEventListener('example', (event) => {
  // Property 'detail' does not exist on type 'Event'.ts(2339)
  console.log(event.detail);// <-- Type error
});

When trying to access event.detail, this leads to a type error:

Property 'detail' does not exist on type 'Event'.ts(2339)

Additional Information

This issue relates to stenciljs/output-targets#219 but is not the same issue. The difference is that this issue is about Stencil as custom elements and consuming them as custom elements with or without Angular and without using the Angular output target.

That being said, a solution would likely resolve the majority of the problems involving Outputs / Events with the Angular output target.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Resolution: RefineThis PR is marked for Jira refinement. We're not working on it - we're talking it through.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions