In a Vue `<script setup lang="ts">` component, how do you type a native `@change` handler so reading the input's value compiles?
answer
- an untyped parameter is implicitly any
- annotate the parameter as an Event
- target is only an EventTarget
- assert or narrow to HTMLInputElement
basics
~10 sAnnotate 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 sA 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
Recall annotating the handler parameter as Event and asserting event.target to HTMLInputElement before reading value.
Explain why target is only EventTarget | null, when to prefer instanceof over an assertion, and how the template types $event.
Prefer v-model or narrowed checks over assertions in shared components, and keep handler types honest when markup changes.
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.