Typing Props and Emits

Declare props and emits with types instead of runtime objects, set defaults, and recognise both emit declaration forms.

Syntaxconst props = defineProps<{ id: number; label?: string }>()

Props and emitted events are a component's public interface. With lang="ts" you declare them as types, and the compiler generates the runtime declaration from them.

Type-only declarations

Pass a type argument to defineProps, for example defineProps<{ id: number; label?: string }>(). An interface works too and reads better past two or three props. A ? makes the prop optional, which means its type includes undefined. defineEmits takes a type argument the same way.

Defaults, short and long

Two forms with the same result. The longhand wraps the declaration: withDefaults(defineProps<Props>(), { ... }), where an object or array default has to be returned from a factory function. Since Vue 3.5 the shorthand is reactive props destructure: destructure the result and give ordinary default values. The compiler rewrites every reference to the variable back into a property access on the props object, so reactivity survives.

Emits, short and long

The call-signature form lists one signature per event: (e: 'change', id: number): void. The object-literal form, added in 3.3, says the same thing as a named tuple of arguments: change: [id: number], and [] for an event with no payload. Both give you the same typed emit.

What trips people up

A destructured prop is a value where you use it. Passing it into a function or a watcher passes that moment's value, so pass () => label when something has to track changes.

Example

Example · vue
<script setup lang="ts">
interface Props {
  label: string
  count?: number
  disabled?: boolean
}

// Vue 3.5 reactive props destructure: defaults are plain values.
const { label, count = 0, disabled = false } = defineProps<Props>()

// Object-literal form (3.3+): each event is a tuple of its arguments.
const emit = defineEmits<{
  increment: [amount: number]
  reset: []
}>()

function bump(): void {
  if (disabled) return
  emit('increment', 1)
}
</script>

<template>
  <div>
    <button type="button" :disabled="disabled" @click="bump">
      {{ label }}: {{ count }}
    </button>
    <button type="button" @click="emit('reset')">Reset</button>
  </div>
</template>

<style scoped>
button {
  margin-right: 0.5rem;
}
</style>

When to use it

  • Giving a shared button or input component an interface a teammate can use without reading its source.
  • Making a prop optional with a default, so every parent does not repeat the same value.
  • Checking an event payload at the parent's handler instead of discovering the mismatch in the browser.
  • Converting a runtime props object to a type-only declaration while moving a component to lang="ts".

More examples

withDefaults, the longhand of destructured defaults

Both forms produce a prop that is optional for the parent and always defined inside the component, so `props.rows` is `number`, not `number | undefined`. withDefaults is what you will find in most existing code and in anything written against Vue 3.4 or earlier; the destructure form is shorter and takes plain values rather than factories.

Example · vue
<script setup lang="ts">
interface Props {
  rows?: number
  labels?: string[]
}

// Longhand: withDefaults wraps the type-only declaration.
// An array or object default must come from a factory function.
const props = withDefaults(defineProps<Props>(), {
  rows: 3,
  labels: () => ['draft', 'sent']
})

// Shorthand (Vue 3.5), same component, plain default values:
// const { rows = 3, labels = ['draft', 'sent'] } = defineProps<Props>()
</script>

<template>
  <textarea :rows="props.rows" :placeholder="props.labels.join(' / ')"></textarea>
</template>

<style scoped>
textarea {
  width: 100%;
}
</style>

The two emits declaration forms side by side

Only one of these can appear in a real component; they are shown together because you need to read both. The call-signature form is older and still common in libraries and blog posts. Pick the object-literal form in new code, and remember an event with no payload needs the empty tuple.

Example · ts
// Call-signature form: one overload per event.
const emitLong = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
  (e: 'close'): void
}>()

// Object-literal form (3.3+): the same three events as argument tuples.
const emitShort = defineEmits<{
  change: [id: number]
  update: [value: string]
  close: []
}>()

// Both give a function with identical call sites:
// emit('change', 1)      ok
// emit('change', '1')    Argument of type 'string' is not assignable to 'number'
// emit('close')          ok

The parent side, where the types pay off

Typed props are checked at the call site, inside the template, by vue-tsc and by the editor extension. The handler argument is checked too: `onIncrement` has to accept a number because that is what the child declared. This is the difference a type-only declaration makes over a runtime one.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import CounterButton from './CounterButton.vue'

const total = ref(0)

function onIncrement(amount: number): void {
  total.value += amount
}
</script>

<template>
  <div>
    <counter-button
      label="Plays"
      :count="total"
      @increment="onIncrement"
      @reset="total = 0"
    />
    <!-- :count="'3'"  vue-tsc: 'string' is not assignable to 'number' -->
    <!-- label omitted  vue-tsc: Property 'label' is missing -->
  </div>
</template>

<style scoped>
div {
  padding: 0.5rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.