Typing Props and Emits
Declare props and emits with types instead of runtime objects, set defaults, and recognise both emit declaration forms.
const 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
<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.
<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.
// 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') okThe 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.
<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