skip to content

In a Vue 3.3+ `<script setup lang="ts">` component, what are the two type syntaxes for defineEmits, and what reaches the runtime?

level: middleimportance: should knowfreq 50%

answer

  1. overloaded call signatures
  2. event name keys with tuple payloads
  3. labels on the tuple elements
  4. only event names survive compilation
  5. payloads are checked by types only

basics

~20 s

Either call signatures, { (e: 'change', value: string): void }, or since 3.3 named tuples, { change: [value: string] }. Both type emit() calls and parent handlers the same way; at runtime the compiler emits only the list of event names.

solid answer

~40 s

`defineEmits` takes a type argument in two shapes. The original is a set of **call signatures**: `defineEmits<{ (e: 'change', value: string): void; (e: 'open'): void }>()`. Vue 3.3 added the more succinct **named-tuple** form: `defineEmits<{ change: [value: string]; open: [] }>()`, where each key is an event and the tuple lists its payload, labels included. Both give the same checking: `emit('change', 42)` is a type error, and a parent's `@change` handler receives a typed `value`. You cannot mix the two forms in one type (compile error), nor pass a type and a runtime argument together. What reaches the runtime is only the event names, `['change', 'open']`, generated by the compiler; payloads are never validated at runtime, and payload validators need the runtime object syntax.

go deeper

for a junior

Recall both defineEmits type syntaxes and that emit calls with a wrong event name or payload are type errors.

for a middle

Explain that only event names reach the runtime and that payload validators need the runtime object form.

for a senior

Decide where runtime validators are worth it and keep one emits style per codebase for readable component APIs.

for a principal

Treat event declarations as a public API: version them deliberately in shared libraries and document payloads through the tuple labels.

## Declaring events with types A component's **emitted events** are its outputs, and `defineEmits` declares them in `<script setup>`. With TypeScript you pass a type argument and no runtime argument; the macro returns the typed `emit` function. For a SelectInput, the events might be `change` (with the selected value), `search` (with a query) and `open` (no payload). ## Syntax 1: call signatures ```ts const emit = defineEmits<{ (e: 'change', value: string): void (e: 'search', query: string): void (e: 'open'): void }>() ``` Each **call signature** is one overload of `emit`: the first parameter is the event name as a string literal, the rest is the payload. It works in every Vue 3 version that supports type-based emits. ## Syntax 2: named tuples (3.3+) ```ts const emit = defineEmits<{ change: [value: string] search: [query: string] open: [] }>() ``` - Each **property key** is an event name. - Each value is a **tuple** of the payload arguments; the labels (`value`, `query`) show up in editor hints. - An **empty tuple** means the event has no payload. - It is shorter and reads like a table of the component's outputs, which is why it has become the common style. ## What both syntaxes check Both produce the same typing of `emit` and of parents: 1. `emit('change', 'de')` compiles; `emit('change', 42)` and `emit('chnage', 'de')` are type errors. 2. In the parent, `<SelectInput @change="onChange" />` requires `onChange` to accept a `string`. 3. An inline handler `@change="(v) => (country = v)"` gets `v` typed as `string`. ## What reaches the runtime The compiler generates the runtime `emits` option from the type, and it contains **only event names**: ```js emits: ['change', 'search', 'open'] ``` | Aspect | Type-based emits | Runtime object emits | |---|---|---| | Event names known at runtime | Yes, generated | Yes | | Payload types checked | At type-check time only | Only if you write validators | | Payload validator functions | Not possible | `{ change: (v) => typeof v === 'string' }` | | Mixed with the other form | Not allowed in one call | Not allowed in one call | So in development, emitting an event that was never declared still warns (`Component emitted event "x" but it is neither declared in the emits option nor as an "onX" prop.`), but a wrong **payload** passes silently at runtime. The payload contract lives entirely in the type checker. ## Rules the compiler enforces - **One form per type literal**: mixing call signatures and properties fails with `defineEmits() type cannot mixed call signature and property syntax.` - **Type or runtime argument, not both**: like `defineProps`, `defineEmits` rejects a type argument together with a runtime argument. - **One call per component**: a duplicate `defineEmits` is a compile error. ## Converting between the two forms | Call signature | Named tuple | |---|---| | `(e: 'change', value: string): void` | `change: [value: string]` | | `(e: 'search', query: string, page: number): void` | `search: [query: string, page: number]` | | `(e: 'open'): void` | `open: []` | | `(e: 'pick', value?: string): void` | `pick: [value?: string]` | The conversion is one line per event and changes no behaviour: the generated runtime names are identical, and parents see the same handler types. Optional payload arguments carry over as optional tuple elements. ## The parent side ```vue <script setup lang="ts"> import { ref } from 'vue' import SelectInput from './SelectInput.vue' const country = ref('de') function onSearch(query: string) { console.log('search', query) } </script> <template> <SelectInput :options="[]" @change="(v) => (country = v)" @search="onSearch" /> </template> ``` Here `v` is inferred as `string` from the child's declaration, and binding a handler that expects a `number` to `@search` would be a template type error. ## Choosing between them - Prefer **named tuples** for new code: shorter, labelled, easy to scan. - Keep **call signatures** where existing code uses them; there is no behavioural gain in converting, only readability. - Use the **runtime object** form when a payload must be validated in development, for instance when events carry data from untrusted input. ## Pitfalls - Declaring `open: void` instead of `open: []`: the value must be a tuple listing the arguments. - Expecting runtime warnings for a wrong payload type. - Forgetting that `defineModel` adds its own `update:*` events; those are typed through `defineModel`, not here.

  • In Vue, how do you declare an event without a payload in the named-tuple defineEmits syntax?
    Give it an empty tuple: `defineEmits<{ open: [] }>()`. Then `emit('open')` compiles and `emit('open', 1)` is a type error, because the tuple lists exactly the arguments after the event name.
  • How would you validate a Vue event's payload at runtime if types are not enough?
    Switch that component to the runtime object syntax, `defineEmits({ change: (value: string) => value.length > 0 })`. In development Vue calls the validator on each emit and warns when it returns false. Type-based declarations cannot carry validators, and the two forms cannot be combined in one call.

saying these in an interview costs you the question

  • Type-based emits validate payload types at runtime in development.
  • The named-tuple syntax works in every Vue 3 version.
  • You can mix call signatures and named tuples in one defineEmits type.
  • A no-payload event is declared as open: void.
  • The compiler emits nothing at runtime for type-based emits.