Skip to content

TypeScript doesn't allow event : CustomEvent in addEventListener #28357

Open

Description

I'm using Visual Studio Code - Insiders v 1.29.0-insider

In my TypeScript project, I'm trying to write the following code:

buttonEl.addEventListener( 'myCustomEvent', ( event : CustomEvent ) => {
  //do something
} );

The problem is that the CustomEvent type gives me the error shown below. If I replace CustomEvent with Event, then there is no error, but then I have difficulty getting event.detail out of the event listener.

"resource": "/c:/Users/me/Documents/app/file.ts",
"owner": "typescript",
"code": "2345",
"severity": 8,
"message": "Argument of type '(event: CustomEvent<any>) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject'.\n  Type '(event: CustomEvent<any>) => void' is not assignable to type 'EventListener'.\n    Types of parameters 'event' and 'evt' are incompatible.\n      Type 'Event' is not assignable to type 'CustomEvent<any>'.\n        Property 'detail' is missing in type 'Event'.",
"source": "ts",
"startLineNumber": 86,
"startColumn": 44,
"endLineNumber": 86,
"endColumn": 72

}

Activity

  1. mjbvz commented on Nov 6, 2018

    @mjbvz

    I can't repo this with the TypeScript 3.1.4:

    const button = document.createElement('button')
    
    button.addEventListener('myCustomEvent', (event: CustomEvent) => {
        //do something
    });
    • What TS version are you using?
    • Can you please share your tsconfig.json?
  2. mmakrzem commented on Nov 6, 2018

    @mmakrzem
    Author

    I'm writing my code using https://stenciljs.com/ which is reporting the following TypeScript version:

    C:\Users\me\Documents\work>npm list typescript
    my@0.0.1 C:\Users\me\Documents\work
    `-- @stencil/core@0.15.2
      `-- typescript@2.9.2
    
    

    My tsconfig.json file looks like this:

    {
      "compilerOptions": {
        "allowSyntheticDefaultImports": true,
        "allowUnreachableCode": false,
        "declaration": false,
        "experimentalDecorators": true,
        "forceConsistentCasingInFileNames": true,
        "inlineSources": true,
        "jsx": "react",
        "jsxFactory": "h",
        "lib": [
          "dom",
          "es2017",
          "dom.iterable"
        ],
        "moduleResolution": "node",
        "module": "esnext",
        "newLine": "lf",
        "noEmitOnError": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitAny": true,
        "noImplicitReturns": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "pretty": true,
        "removeComments": true,
        "skipLibCheck": true,
        "sourceMap": true,
        "strict": true,
        "target": "es2017"
      },
      "include": [
        "src",
        "types/jsx.d.ts"
      ],
      "exclude": [
        "node_modules"
      ]
    }
    
  3. mmakrzem commented on Nov 6, 2018

    @mmakrzem
    Author

    In the lib.dom.d.ts file I have the following definition for HTMLElement's addEventListener:

    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;
    

    where:

    declare type EventListenerOrEventListenerObject = EventListener | EventListenerObject;

    and

    interface EventListener {
        (evt: Event): void;
    }
    
    interface EventListenerObject {
        handleEvent(evt: Event): void;
    }
    

    How is the addEventListener defined in your version of TypeScript? and how would I update it on my PC if it is a dependency of Stencil. In my package.json, I just have the following devDependencies defined, note TypeScript is not listed anywhere:

      "devDependencies": {
        "@stencil/core": "^0.15.2",
        "tslint": "^5.11.0"
      },
    
  4. added
    Domain: lib.d.tsThe issue relates to the different libraries shipped with TypeScript
    Needs InvestigationThis issue needs a team member to investigate its status.
    on Nov 6, 2018
  5. msheakoski commented on Nov 7, 2018

    @msheakoski

    I have always needed to write it like this to avoid the issue with custom events:

    buttonEl.addEventListener('myCustomEvent', ((event: CustomEvent) => {
      //do something
    }) as EventListener);
  6. saschanaz commented on Nov 8, 2018

    @saschanaz
    Contributor

    strictFunctionTypes causes this issue. Try strictFunctionTypes: false on tsconfig.

  7. essenmitsosse commented on Feb 6, 2019

    @essenmitsosse

    What is the proper solution to this - or what is the reason why it creates an error in the first place?

  8. mmakrzem commented on Feb 6, 2019

    @mmakrzem
    Author

    What is the proper solution to this - or what is the reason why it creates an error in the first place?

    I have been using msheakoski's solution. It is verbose but works. Ideally the EventListenerOrEventListenerObject would be updated to include CustomEventListener

  9. yashsway commented on Apr 10, 2019

    @yashsway

    I have always needed to write it like this to avoid the issue with custom events:

    buttonEl.addEventListener('myCustomEvent', ((event: CustomEvent) => {
      //do something
    }) as EventListener);

    I can confirm that I still have to do this. Right now, I have something like this:

    variableFromTheScopeOfTheFunction = 'some parameter';
    ...
    ...
    ['click', 'touchend'].forEach(handler => document.addEventListener(handler, this.genEventTrigger(this.variableFromTheScopeOfTheFunction)));
    genEventTrigger(param: any) {
     return (event: Event) => {
       // const someVar = this.variableFromTheScopeOfTheFunction; <- can't do this because, 'this' here will refer to the document, not the scope of the function itself
       const someVar = param; // have to do this INSTEAD, so it's set during compile time
       // do things here
     };
    }

    At this point, Typescript complains that the function signature is invalid for an EventListener.

    Adding as EventListener, fixes it:

    ['click', 'touchend'].forEach(handler => document.addEventListener(handler, this.genEventTrigger('a compile time parameter') as EventListener));

    It's quite silly. Unless I could be doing something better, feel free to correct me!

  10. Nikohelie commented on Jul 29, 2019

    @Nikohelie

    I use an other workaround which keep a type guard in the addEvenListener.

    Because the interface of EventListener is contravariance rules we need to check if the event receive in our addEvenlistener contains the params detail.
    For that, you could define an utils function like that

    function isCustomEvent(evt: Event): evt is CustomEvent {
    return (evt as CustomEvent).detail !== undefined;
    }

  11. revmischa commented on Aug 1, 2019

    @revmischa

    I also have this issue - I want to define a subclass of Event that has custom fields on it. If there was a generic type argument for EventListener maybe that would help?

    currently I have to do:

    interface IUseWebSocketClientArgs {
      onEvent?: (evt: WSEvent) => void
    }
    ...
    client.addEventListener(WEBSOCKET_EVENT, onEvent as EventListener)

    something like this might work?

    interface IUseWebSocketClientArgs {
      onEvent?: EventListener<WSEvent>
    }
    ...
    client.addEventListener(WEBSOCKET_EVENT, onEvent)
  12. jamie-pate commented on Sep 16, 2019

    @jamie-pate

    lib.dom.d.ts also uses this definition if it helps you define your signatures:

    addEventListener<K extends keyof HTMLElementEventMap>(
        type: K,
        listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions
    ): void;

    so, for instance if you want a key/value map of events in an object:

    type Events = {
        [K in keyof HTMLElementEventMap]:
            (this: HTMLElement, event: HTMLElementEventMap[K]) => void
    };
  13. 23 remaining items

  14. greggman commented on Mar 18, 2022

    @greggman
    Contributor

    Okay, I think I worked it out

    interface FizzInfo {
      amount: string;
    }
    
    interface BuzzInfo {
      level: number;
    }
    
    interface FizzBuzzEventMap {
      fizz: CustomEvent<FizzInfo>;
      buzz: CustomEvent<BuzzInfo>;
    }
    
    interface FizzerBuzzer extends EventTarget {
      addEventListener<K extends keyof FizzBuzzEventMap>(type: K, listener: (this: FizzerBuzzer, ev: FizzBuzzEventMap[K]) => void, options?: boolean | AddEventListenerOptions): void;
      addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
      removeEventListener<K extends keyof FizzBuzzEventMap>(type: K, listener: (this: FizzerBuzzer, ev: FizzBuzzEventMap[K]) => void, options?: boolean | EventListenerOptions): void;
      removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
    }
    
    class FizzerBuzzer extends EventTarget {
      numFizz: number = 0;
      numBuzz: number = 0;
    
      start(): void {
        setInterval(() => this.emitFizz(), 3000);
        setInterval(() => this.emitBuzz(), 5000);
      }
      emitFizz(): void {
        ++this.numFizz;
        this.dispatchEvent(new CustomEvent<FizzInfo>('fizz', {
          detail: { amount: this.numFizz.toString() },
        }));
      }
      emitBuzz(): void {
        ++this.numBuzz;
        this.dispatchEvent(new CustomEvent<BuzzInfo>('buzz', {
          detail: { level: this.numBuzz },
        }));
      }
    }
    
    
    const fb = new FizzerBuzzer();
    fb.addEventListener('fizz', (ev) => {
      console.assert(typeof ev.detail.amount === 'string', 'bad!');
      console.log(ev.detail.amount);
    });
    fb.addEventListener('buzz', (ev) => {
      console.assert(typeof ev.detail.level === 'number', 'bad');
      console.log(ev.detail.level);
    });
    
  15. ferdodo commented on Apr 9, 2022

    @ferdodo

    This issue is still relevant, there are only workarounds in this thread to achieve this:

    Different means to the same end:

    element.addEventListener('myCustomEvent', (event: Event) => {
        const detail = (event as CustomEvent).detail;
    });
    
  16. citkane commented on Aug 17, 2022

    @citkane

    I found this thread while intending to file a new bug report.
    Agreed Thomas Riffard (@ferdodo) , this thread is still relevant and has only workarounds but no recognition of this as a bug since 2018.

    Would an admin maybe tag this thread as a bug?
    Should I create a new issue tagged as a bug?

    Here is the problem illustrated on the typescript playground. Typescript v4.7.4

    It is persisting into 4.8Beta and Nightly.

  17. LukasBombach commented on Sep 28, 2022

    @LukasBombach

    I regard this as a bug. MDN states that you can dispatch a CustomEvents to EventTargets, yet TypeScript does not recognize this. Compare EventTarget on MDN

    This works on all modern browsers.

    The interface is typed as

    lib.dom.ts

    interface EventListener {
        (evt: Event): void;
    }
    
    interface EventListenerObject {
        handleEvent(object: Event): void;
    }

    That is a mismatch. It should be

    -    (evt: Event): void;
    +    (evt: Event | CustomEvent): void;

    and

    -    handleEvent(object: Event): void;
    +    handleEvent(object: Event | CustomEvent): void;

    What's curious though is that the WHATWG specs do not reflect this:

    https://dom.spec.whatwg.org/#interface-eventtarget

    Maybe this is the reason / root cause?

  18. ChristophP commented on Jan 1, 2023

    @ChristophP

    I'm seeing this issue as well. This bug seems to have existed for quite a while now.

  19. sejori commented on Jan 19, 2023

    @sejori

    I also want to report that this is definitely a bug in my opinion. Here I want to supply a listener that is designed to receive CustomEvents and then apply the listener function to ancestor nodes in a tree of event targets:

      addBranchEventListener(type: string, listener: (e: CustomEvent) => void) {
        this.addEventListener(type, (e) => listener(e as CustomEvent))
    
        if (this.parent) this.parent.addBranchEventListener(type, listener)
      }
    

    It would be nice to not need the type assertion here.

  20. saschanaz commented on Jan 19, 2023

    @saschanaz
    Contributor

    This thread has several suggestions to extend EventMap interfaces to define your own custom event. I think that's the way to go, because otherwise things may break whenever HTML define a new event, which would then suddenly fire non-custom events.

    Edit: See also microsoft/TypeScript-DOM-lib-generator#1535 (comment).

  21. mkcode commented on Apr 1, 2023

    @mkcode

    Chiming in to say that this bug is still at large!

    Lukas Bombach (@LukasBombach) describes how this should be fixed: #28357 (comment)

    I no longer thing this a bug. See here

  22. mkcode commented on Apr 1, 2023

    @mkcode

    ...and if people get to this in the issue, please consider that it is a much better TypeScript pattern to define your types in the object itself, rather than in the callback, which avoids some of the issues that many are having here.

    Kagami Sascha Rosylight (@saschanaz) pointed out that these methods should be used

  23. wesbos commented on Apr 24, 2023

    @wesbos

    Here is an example based on the above code. Still noodling on how to best approach the dispachEvents inferrance

    interface EvMap {
      "user:updated": CustomEvent<{ name: string, age: number }>,
    }
    
    interface UserInterface extends EventTarget {
      addEventListener<K extends keyof EvMap>(event: K, listener: ((this: UserInterface, ev: EvMap[K]) => any) | null, options?: AddEventListenerOptions | boolean): void;
      addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean): void;
    }
    
    class UserInterface {
      updateUser(user: User) {
        this.dispatchEvent(new CustomEvent('user:updated', { detail: {
          age: 100,
          name: 'Wes Bos'
        } }));
      }
    }
    
    const instance = new UserInterface();
    
    instance.addEventListener('user:updated', (event) => {
      event.detail.name; // string
      event.detail.age; // number
      event.detail.doesntExist; // error
    });
  24. panoply commented on May 24, 2023

    @panoply

    Adding this here for the sake of brevity.

    Similar to how you'd extend the Window (globalThis) object, the same logic can be applied here via the WindowEventMap within a definition file:

    interface Example {
       foo: string;
       bar: string;
    }
    
    declare global {
        interface WindowEventMap {
          'custom:event-1': CustomEvent<Example>;
          'custom:event-2': CustomEvent<Example>;
        }
    }
  25. mp3por commented on Mar 20, 2024

    @mp3por

    FYI this is how I did it without any additional changes - I just make sure I type the handler correctly and it works

    image
  26. commanderz commented on Jun 29, 2024

    @commanderz

    Confirm that Custom Events working in typescript without custom dom.d.ts file, just use it like this:

    My types:

    export const CustomConfirmationEventType = "confirmationDialogConfirm"
    export enum YesNoCancel {
        YES = 'YES',
        NO = 'NO',
        CANCEL = 'CANCEL'
      }
      
    export interface ConfirmationDialogState {
        isOpenConfirm: boolean;
        confirmLabel: string;
        innerText: string;
        confirmYesLabel: string;
        confirmNoLabel: string;
    }
    
    const initialState: ConfirmationDialogState = {
        isOpenConfirm: false,
        confirmLabel: "",
        innerText: "",
        confirmYesLabel: "",
        confirmNoLabel: "",
    };
    
    export interface CustomConfirmationEvent extends Partial<Event> {
        detail?: {
            message: string;
        }
    }

    My custom hook useConfirmationDialog.ts:

    import { useAppDispatch } from '@/app/hooks';
    import { useCallback } from 'react';
    import { ConfirmationDialogState,closeConfirmationDialog,openConfirmationDialog, CustomConfirmationEvent,CustomConfirmationEventType, YesNoCancel}
     from '@/store/confirm'
    
    const useConfirmationDialog = () => {
      const dispatch = useAppDispatch()
    
      const showConfirmationDialog = useCallback( (props: ConfirmationDialogState) => {
          return new Promise<YesNoCancel>((resolve) => {
            
            const handleConfirm:EventListener = (event: CustomConfirmationEvent) => {
              //this.[...] // this is Document
              //event.detail ... //is your CustomParams type.
              //console.log(JSON.stringify(event.detail?.message),event.type)   //"YES" confirmationDialogConfirm 
              dispatch(closeConfirmationDialog())
              resolve(event.detail?.message as YesNoCancel)
            };
            dispatch( openConfirmationDialog(props))
    
            // Підписуємося на події підтвердження та скасування
            document.addEventListener(CustomConfirmationEventType, handleConfirm, { once: true })
            
            // Функція очищення
            return () => {
              document.removeEventListener(CustomConfirmationEventType, handleConfirm)
            };
          });
        },
        [dispatch]
      );
    
      return { showConfirmationDialog };
    };
    
    export default useConfirmationDialog;

    code in ConfirmationDialog.tsx:

      const param:ConfirmationDialogState = useAppSelector( selectConfirmationDialog)
      
      const handleConfirm = () => {
        const x = new CustomEvent(CustomConfirmationEventType, {detail:{message:YesNoCancel.YES}})
        document.dispatchEvent(x)
      }
      const handleNo = () => {
        const x = new CustomEvent(CustomConfirmationEventType, {detail:{message:YesNoCancel.NO}})
        document.dispatchEvent(x)
      }
      const handleCancel = () => {
        const x = new CustomEvent(CustomConfirmationEventType, {detail:{message:YesNoCancel.CANCEL}})
        document.dispatchEvent(x)
      };

    and hook useConfirmationDialog used like this:

    const {showConfirmationDialog} =  useConfirmationDialog()
    const handleLogout = () => {
        showConfirmationDialog(
          {
            isOpenConfirm: true,
            confirmLabel: 'Вихід с кабінету',
            innerText: 'Бажаєте вийти з кабінету?\n(Буде очищено кеш та сховище браузера від даних сайту)',
            confirmYesLabel: 'Так, вийти',
            confirmNoLabel: 'Ні, залишитись',
          }
        ).then((value)=>{
           switch (value) {
            case YesNoCancel.YES: { dispatch(runLogout(navigate)); return;}
            case YesNoCancel.NO: { dispatch(setMessage({message:'З поверненням 😘',open:true})); return;}
            case YesNoCancel.CANCEL: {return;}
           }
        }).catch(()=>{
           null
        })
      }
    
  27. morris commented on Aug 26, 2024

    @morris

    Following up on Wes Bos (@wesbos), this is how I solve it for dispatching as well:

    /**
     * Type-safe event listener and dispatch signatures for the custom events
     * defined in `TDetails`.
     */
    export type CustomEventTarget<TDetails> = {
      addEventListener<TType extends keyof TDetails>(
        type: TType,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        listener: (ev: CustomEvent<TDetails[TType]>) => any,
        options?: boolean | AddEventListenerOptions,
      ): void;
    
      removeEventListener<TType extends keyof TDetails>(
        type: TType,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        listener: (ev: CustomEvent<TDetails[TType]>) => any,
        options?: boolean | EventListenerOptions,
      ): void;
    
      dispatchEvent<TType extends keyof TDetails>(
        ev: _TypedCustomEvent<TDetails, TType>,
      ): void;
    };
    
    /**
     * Extends an element type with type-safe event listener signatures for the
     * custom events defined in `TDetails`.
     */
    export type CustomEventElement<
      TDetails,
      TElement = HTMLElement,
    > = CustomEventTarget<TDetails> & TElement;
    
    /**
     * Internal declaration for the `typeof` trick below.
     * Never actually implemented.
     */
    declare class _TypedCustomEvent<
      TDetails,
      TType extends keyof TDetails,
    > extends CustomEvent<TDetails[TType]> {
      constructor(
        type: TType,
        eventInitDict: { detail: TDetails[TType] } & EventInit,
      );
    }
    
    /**
     * Typed custom event (technically a typed alias of `CustomEvent`).
     * Use with `CustomEventTarget.dispatchEvent` to infer `detail` types
     * automatically.
     */
    export const TypedCustomEvent = CustomEvent as typeof _TypedCustomEvent;

    With that setup you can do:

    // Map of event type -> detail
    interface UserEvents {
      'user:updated': { name: string; age: number };
    }
    
    const div: CustomEventElement<UserEvents> = document.createElement('div');
    
    div.addEventListener('user:updated', (e) => {
      // e.detail is inferred to { name: string; age: number };
    });
    
    div.dispatchEvent(
      new TypedCustomEvent('user:updated', {
        // detail is inferred to { name: string; age: number };
        detail: { name: 'Bob', age: 42 },
      }),
    );

    It has zero runtime overhead (TypedCustomEvent is really just CustomEvent) and is fully type-safe, I think. Not sure if the declaration "trick" is actually necessary, maybe it can be done more succinctly :)

  28. TangoPJ commented on Apr 4, 2025

    @TangoPJ

    I have always needed to write it like this to avoid the issue with custom events:

    buttonEl.addEventListener('myCustomEvent', ((event: CustomEvent) => {
    //do something
    }) as EventListener);

    I solved this problem in the following way:

    window.addEventListener('elementClicked', handleElementClick as unknown as EventListener)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Domain: lib.d.tsThe issue relates to the different libraries shipped with TypeScriptNeeds InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions