skip to content

In a Vue `<script setup lang="ts">` component, how do you type a native `@change` handler so reading the input's value compiles?

level: juniorimportance: should knowfreq 40%

answer

  1. an untyped parameter is implicitly any
  2. annotate the parameter as an Event
  3. target is only an EventTarget
  4. assert or narrow to HTMLInputElement

basics

~10 s

Annotate the parameter, function onChange(event: Event), then narrow the target: (event.target as HTMLInputElement).value, or an instanceof check. Without the annotation the parameter is implicitly any, which strict TypeScript rejects.

solid answer

~40 s

A handler written as `function onChange(event) { ... }` has an implicit `any` parameter, which is an error under `strict` or `noImplicitAny`. The Vue docs' fix is to annotate it, `function onChange(event: Event)`, or a more specific interface such as `KeyboardEvent` for `@keydown`. That alone does not make `event.target.value` compile: `target` is typed `EventTarget | null`, which has no `value`, because TypeScript cannot know which element fired. So you narrow it: `(event.target as HTMLInputElement).value`, as the docs show, or the safer `if (event.target instanceof HTMLInputElement)`. In the template, Vue's DOM typings map each native listener to its event type (for example `@input` to `InputEvent`, `@keydown` to `KeyboardEvent`), and the language tools use them to check the handler you bind and to type `$event` in inline handlers.

go deeper

for a junior

Recall annotating the handler parameter as Event and asserting event.target to HTMLInputElement before reading value.

for a middle

Explain why target is only EventTarget | null, when to prefer instanceof over an assertion, and how the template types $event.

for a senior

Prefer v-model or narrowed checks over assertions in shared components, and keep handler types honest when markup changes.

for a principal

Set lint and tsconfig strictness so implicit any in handlers fails the build rather than relying on review.

## The starting point: implicit `any` In a `<script setup lang="ts">` block, an event handler is an ordinary TypeScript function. Written without a parameter type, its parameter is **implicitly `any`**: ```vue <script setup lang="ts"> function handleChange(event) { // event is implicitly any console.log(event.target.value) } </script> <template> <input type="text" @change="handleChange" /> </template> ``` With `"strict": true` or `"noImplicitAny": true` in `tsconfig.json`, that is a compile error. Even without those flags, `any` switches off every check on the event, which is why the Vue docs recommend annotating handler parameters explicitly. ## Step 1: annotate the event ```ts function handleChange(event: Event) { console.log((event.target as HTMLInputElement).value) } ``` Pick the **most specific event interface** that matches the listener: | Listener | Event type in Vue's DOM typings | |---|---| | `@change` | `Event` | | `@input` | `InputEvent` | | `@keydown` | `KeyboardEvent` | | `@focus`, `@blur` | `FocusEvent` | | `@submit` | `SubmitEvent` | A handler typed with a wider type (`Event` for `@input`) is fine; it simply sees fewer fields. ## Step 2: narrow the target The annotation does not solve the `value` problem. On every DOM event, **`target` is typed `EventTarget | null`**: the event could have bubbled from any node, so TypeScript cannot assume an input. You narrow it in one of two ways: 1. **Type assertion**, as in the docs: `(event.target as HTMLInputElement).value`. Concise, but it is a promise TypeScript does not check; if the handler is later attached to a `<select>`, it still compiles. 2. **Runtime narrowing**: `if (event.target instanceof HTMLInputElement) { ... }`. Slightly longer, but the check is real and the type follows from it. `event.currentTarget` (the element the listener is attached to) is typed the same loose way, so it needs the same narrowing. ## What the template adds The Vue language tools check templates against Vue's DOM typings, which map each native listener on each element to its event type. Two practical effects: - **Binding a handler with an incompatible parameter type** is reported in the template under strict settings; a `(e: KeyboardEvent) => void` handler on `@input` is flagged. - In an **inline handler**, `$event` is typed from the same map: `@input="onInput($event)"` passes an `InputEvent`. This concerns **native DOM events**. Events emitted by a child component with `defineEmits` are typed by that component's emits declaration, not by the DOM map. ## Keyboard and form examples More specific event types unlock more fields, and they are checked: ```ts declare function submit(): void declare function save(data: Record<string, FormDataEntryValue>): void function onKeydown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) submit() } function onSubmit(event: SubmitEvent) { const form = event.target as HTMLFormElement const data = new FormData(form) save(Object.fromEntries(data)) } ``` 1. `event.key` and `event.shiftKey` exist on `KeyboardEvent` but not on the base `Event`, so the narrower annotation is what lets that code compile. 2. The form still needs a target assertion or an `instanceof` check, for the same `EventTarget` reason. 3. Event modifiers such as `@submit.prevent` or `@keydown.enter` change **when** the handler runs, not the handler's type. ## Often, you do not need the event at all For reading an input's value, `v-model` on a typed ref avoids the event object entirely: ```vue <script setup lang="ts"> import { ref } from 'vue' const query = ref('') </script> <template> <input v-model="query" type="search" /> </template> ``` Typed handlers remain necessary for keyboard shortcuts, drag and drop, file inputs and anything that inspects the event itself. ## Pitfalls - Annotating `event: any` to silence the error: it compiles and checks nothing. - Assuming `event.target` is the element the listener is on; with bubbling it can be a descendant. - Asserting to the wrong element interface, such as `HTMLInputElement` on a `<textarea>`, which the assertion will not catch. - Expecting a framework-specific synthetic event wrapper: Vue passes the **native** DOM event. - Reading `event.target.files` on a file input without narrowing: `files` exists only on `HTMLInputElement`, and it is `FileList | null`, so both the target and the list need guards. - Declaring handlers as arrow functions inline in the template to dodge typing: inline handlers get `$event` typed, but long inline logic is harder to read and test than a named, typed function.

  • Why is `(event.target as HTMLInputElement).value` less safe than an instanceof check in a Vue handler?
    The assertion is a claim TypeScript accepts without proof. If the handler is later bound to a `<select>` or the event bubbles from another element, it still compiles and reads the wrong thing. `event.target instanceof HTMLInputElement` checks at runtime, and TypeScript narrows the type inside the branch.
  • In a Vue template with lang="ts", what type does `$event` have in `@keydown="onKey($event)"`?
    `KeyboardEvent`. The Vue language tools type native listeners from Vue's DOM typings, which map `keydown` to `KeyboardEvent`, so `onKey` should accept `KeyboardEvent` (or a wider `Event`).

saying these in an interview costs you the question

  • Annotating the parameter as Event makes event.target.value compile.
  • event.target is typed as the element the listener is attached to.
  • Vue wraps native events in its own synthetic event type.
  • Typing the parameter as any is the recommended way to avoid strict-mode errors.
  • Component events from defineEmits are typed by the DOM event map.