Type Event Handlers in TypeScript

Learn how to type event handler parameters in TypeScript so the compiler knows which properties exist on click, input, keyboard, and other DOM events.

7 min read

When you call element.addEventListener with a standard event name like "click" or "keydown", TypeScript already knows what type the event parameter should be. You do not need to write a type annotation on the callback. The compiler maps every standard DOM event name to its event interface, and the callback parameter gets that type automatically.

How event type inference works

TypeScript uses a mapping called HTMLElementEventMap that connects event name strings to event interfaces. When you pass a standard event name to addEventListener, the callback parameter type is inferred from that mapping.

typescripttypescript
const button = document.querySelector('button');
if (button === null) throw new Error('Button not found');
 
button.addEventListener('click', (event) => {
  //    ^? (parameter) event: MouseEvent
  console.log(event.clientX, event.clientY);
});

The event parameter is MouseEvent without you writing the type. TypeScript inferred it from the string "click". Here is how common event names map to their types:

Event nameInferred event type
click, dblclick, mousedownMouseEvent
keydown, keyupKeyboardEvent
input, changeEvent
submitSubmitEvent
focus, blurFocusEvent
scrollEvent

Keyboard events

When you listen for keydown or keyup, the event is KeyboardEvent. You get key, code, and modifier properties without any extra typing work.

typescripttypescript
document.addEventListener('keydown', (event) => {
  //    ^? (parameter) event: KeyboardEvent
  console.log(event.key);     // 'Enter', 'a', 'Escape'
  console.log(event.code);    // 'KeyA', 'Escape'
  console.log(event.ctrlKey); // boolean
});

KeyboardEvent adds the logical key value through .key and the physical key code through .code. Both are available because TypeScript knows the event type from the event name alone. You never need to cast or annotate the parameter.

Input and change events

Input and change events are typed as Event, not a more specific type like InputEvent. This follows the DOM specification where input events use the base Event interface. The base Event type does not have .target.value, so you need to narrow the target.

TypeScript does not narrow event.currentTarget to the element you attached the listener to. Both event.target and event.currentTarget keep the generic EventTarget or null type, no matter which element type the listener is bound to:

typescripttypescript
const nameInput = document.querySelector<HTMLInputElement>('#name');
if (nameInput === null) throw new Error('Input not found');
 
nameInput.addEventListener('input', (event) => {
  const input = event.currentTarget;
  //    ^? const input: EventTarget | null
});

The simplest fix is to skip currentTarget and read the outer variable instead, since the callback still has access to nameInput through the closure:

typescripttypescript
nameInput.addEventListener('input', () => {
  console.log(nameInput.value); // OK, nameInput is already HTMLInputElement
});

If you need the value from the event object itself, assert the type because you know which element the listener is attached to:

typescripttypescript
nameInput.addEventListener('input', (event) => {
  const input = event.currentTarget as HTMLInputElement;
  console.log(input.value); // OK
});

event.target vs event.currentTarget

These two properties serve different purposes, but TypeScript types both of them as EventTarget or null. Narrow or assert either one before reading element-specific properties.

PropertyTypeWhat it points to
event.targetEventTarget or nullThe element that fired the event (may be a child)
event.currentTargetEventTarget or nullThe element the listener is attached to

When a click on a span inside a button bubbles up, event.target is the span but event.currentTarget is the button. Prefer currentTarget when you need to interact with the element that has the listener. Narrow target with instanceof when you need the exact source element:

typescripttypescript
document.addEventListener('click', (event) => {
  if (event.target instanceof HTMLButtonElement) {
    console.log(event.target.disabled); // OK
  }
});

Submit events

Submit events give you SubmitEvent, which includes a submitter property that identifies which button triggered the submission. This is useful when a form has multiple submit buttons with different purposes like "Save Draft" and "Publish."

typescripttypescript
const form = document.querySelector<HTMLFormElement>('#signup');
if (form === null) throw new Error('Form not found');
 
form.addEventListener('submit', (event) => {
  event.preventDefault();
 
  const submitter = event.submitter;
  console.log(submitter);
});

SubmitEvent extends Event and adds submitter, typed as HTMLElement or null. Narrow it with instanceof to access button-specific properties like name, which tells you which button the user clicked:

typescripttypescript
if (submitter instanceof HTMLButtonElement) {
  console.log(submitter.name); // e.g., 'save-draft' vs 'publish'
}

Custom events

For custom events, use CustomEvent with a generic describing the detail payload. The generic only applies when you create the event with new CustomEvent, so dispatching is fully typed:

typescripttypescript
interface CartUpdate {
  productId: string;
  quantity: number;
}
 
const cartEvent = new CustomEvent<CartUpdate>('cart-update', {
  detail: { productId: 'abc-123', quantity: 2 },
});
 
document.dispatchEvent(cartEvent);

Listening is a different story. addEventListener does not know about custom event names, so the callback parameter is the generic Event type, not CustomEvent. Assert the type inside the listener to get the detail property:

typescripttypescript
document.addEventListener('cart-update', (event) => {
  const cartUpdateEvent = event as CustomEvent<CartUpdate>;
  console.log(cartUpdateEvent.detail.productId); // 'abc-123'
  console.log(cartUpdateEvent.detail.quantity);  // 2
});

This assertion is safe as long as you only dispatch 'cart-update' with a CartUpdate detail elsewhere in your code. TypeScript cannot verify that connection automatically, since string event names are not tracked between dispatchEvent and addEventListener.

Common mistakes

Reading .value directly from event.target or event.currentTarget without narrowing is the most common error. Both stay typed as EventTarget or null, not an input element, so .value does not exist on either without a type assertion or an instanceof check.

Using the wrong event name for the expected type is also frequent. The string "keydown" gives KeyboardEvent, not InputEvent. Check the event mapping table above if the type does not match what you expect.

Forgetting that a type assertion does not check anything at runtime leads to avoidable crashes. Asserting event.currentTarget as HTMLInputElement only silences the compiler. Use instanceof instead when the listener could be attached through delegation to more than one element type.

See Type DOM Elements in TypeScript for element type basics. See Type Form Elements in TypeScript for form-specific event patterns.

Rune AI

Rune AI

Key Insights

  • TypeScript infers the event type from the event name: 'click' gives MouseEvent, 'keydown' gives KeyboardEvent.
  • The callback parameter is automatically typed when you use a standard event name.
  • event.target and event.currentTarget are both typed as EventTarget or null, even when you know which element the listener is attached to.
  • Narrow event.target or event.currentTarget with instanceof, or assert the element type, before reading element properties.
  • Custom events use CustomEvent with a generic for dispatching, then a type assertion inside the listener to read the detail property.
RunePowered by Rune AI

Frequently Asked Questions

How does TypeScript know which event type to use for addEventListener?

TypeScript maps event names to their corresponding event types. 'click' maps to MouseEvent, 'keydown' maps to KeyboardEvent, and 'input' maps to Event. This mapping comes from HTMLElementEventMap in the DOM type definitions.

Can I type custom events in TypeScript?

Yes. Use CustomEvent with a generic describing the detail payload when you create and dispatch the event. addEventListener does not know custom event names, so cast the event parameter to CustomEvent with your detail type inside the listener.

Why does event.target have type EventTarget instead of the element type?

event.target and event.currentTarget both stay typed as EventTarget or null because TypeScript cannot know which element fired the event or which element the listener is attached to. Narrow with instanceof or assert the specific element type before reading element properties.

Conclusion

TypeScript maps every standard DOM event name to its event type automatically. When you write element.addEventListener with a standard event name, the callback parameter is already typed. For custom events, use CustomEvent with a generic and cast inside the listener. event.target and event.currentTarget both need narrowing or a type assertion before you read element properties.