Typed and Validated Events

Declare what each event carries, validate the payload during development, and name events so the parent's template reads as a sentence.

Syntaxconst emit = defineEmits<{ submit: [payload: Credentials]; cancel: [] }>()

An event is how a child reports something upward. defineEmits declares the events a component can emit, and declaring them buys you three things: the names are documented, the payload is type checked, and the listeners stop falling through as DOM attributes.

Declaring the payload shape

In TypeScript there are two forms, and they mean the same thing. The named-tuple form, available from Vue 3.3, is shorter:

defineEmits<{ change: [id: number] }>()
defineEmits<{ (e: 'change', id: number): void }>()

An event with no payload is an empty tuple: cancel: []. The call returns an emit function, and calling it with the wrong argument count or type now fails type checking.

Validating an emit

In plain JavaScript, pass an object instead of an array. Each key maps to a function that receives the emit arguments and returns a boolean. null means no validation. A validator that returns false logs a console warning in the development build; the event still reaches the parent, and the check is gone from the production build.

Naming

Emit in camelCase and listen in kebab-case. A parent reading @file-selected or @retry-requested should not need to open the child. Past tense for something that happened, and update: prefixes for values that flow back up.

What trips people up

An undeclared listener lands in $attrs and is bound to the root element as a native DOM listener. So @close on a component with a root <button> waits for a DOM close event that never arrives and the handler silently never runs, while an undeclared @click fires on every click anywhere in that root element.

Example

Example · vue
<!-- src/components/SignInForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'

interface Credentials {
  email: string
  password: string
}

const emit = defineEmits<{
  submit: [credentials: Credentials]
  cancel: []
}>()

const email = ref('')
const password = ref('')

function submit() {
  emit('submit', { email: email.value, password: password.value })
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="email">Email</label>
    <input id="email" v-model="email" type="email" required />

    <label for="password">Password</label>
    <input id="password" v-model="password" type="password" required />

    <p>
      <button type="submit">Sign in</button>
      <button type="button" @click="emit('cancel')">Cancel</button>
    </p>
  </form>
</template>

<style scoped>
form {
  display: grid;
  gap: 0.5rem;
  max-width: 20rem;
}
</style>

When to use it

  • Give a table row a rowSelected event carrying the record id, type checked in both parent and child.
  • Warn during development when a date-range component emits a start later than its end.
  • Replace a callback passed down as a prop with a declared event so it does not also land on the DOM.
  • Rename a payload field once and let type checking find every parent that reads it.

More examples

How the parent reads

The payload type declared in the child is the parameter type the parent's handler must accept, so renaming a field in Credentials breaks type checking in both files at once. Note the casing rule: an event emitted as fileSelected is listened to as @file-selected, and both spellings refer to the same event.

Example · vue
<!-- src/views/SignInView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import SignInForm from '../components/SignInForm.vue'

interface Credentials {
  email: string
  password: string
}

const status = ref('Waiting')

function signIn(credentials: Credentials) {
  status.value = `Signing in ${credentials.email}`
}

function dismiss() {
  status.value = 'Cancelled'
}
</script>

<template>
  <section>
    <sign-in-form @submit="signIn" @cancel="dismiss" />
    <p>{{ status }}</p>
  </section>
</template>

Runtime validation without TypeScript

Two number inputs and two buttons; Apply reports the range upward and Reset reports nothing. The object form of defineEmits is the only way to validate a payload at runtime, which makes it useful in a JavaScript project or where the payload comes from user input. Use null for events you do not want to check.

Example · vue
<!-- src/components/RangePicker.vue -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits({
  // no validation for this one
  reset: null,

  // reject a range that runs backwards
  rangeChanged: ({ from, to }) => {
    if (from <= to) {
      return true
    }
    console.warn('rangeChanged: from must not be after to')
    return false
  }
})

const from = ref(1)
const to = ref(10)

function apply() {
  emit('rangeChanged', { from: from.value, to: to.value })
}
</script>

<template>
  <p>
    <input v-model.number="from" type="number" />
    <input v-model.number="to" type="number" />
    <button type="button" @click="apply">Apply</button>
    <button type="button" @click="emit('reset')">Reset</button>
  </p>
</template>

Naming events so the parent reads clearly

A naming convention worth holding to across a codebase: a past-tense name for something that has happened, a -Requested suffix for an intention the parent must act on, and the update: prefix reserved for a value flowing back to its owner. The update: prefix is not decoration; it is the name v-model on a component looks for.

Example · ts
// Vague: the parent cannot tell what arrives or when.
type Vague = {
  change: [unknown]
  click: []
  data: [unknown[]]
}

// Specific: past tense for a fact, update: for a value going back up.
type Clear = {
  rowSelected: [id: number]
  retryRequested: []
  'update:query': [value: string]
}

const listeners = Object.keys({
  rowSelected: true,
  retryRequested: true,
  'update:query': true
})
console.log(listeners.map((name) => '@' + name))
// Output: [ '@rowSelected', '@retryRequested', '@update:query' ]

Discussion

  • Be the first to comment on this lesson.