defineProps, defineEmits, and defineExpose in Vue.js
defineProps, defineEmits, and defineExpose are compiler macros in Vue 3's <script setup> that declare what a component accepts, signals out, and lets parents access, all without manual imports.
Theory
TL;DR
- Think of a component as an apartment:
definePropsis the door lock (what comes in),defineEmitsis the doorbell (what signals out),defineExposeopens a specific window (what an outsider can reach) - Main difference from Options API: these macros give TypeScript inference automatically, no manual
props: {}object needed definePropswhen a parent passes data,defineEmitsfor any event or v-model,defineExposeonly when a parent needs to call a method imperatively- In
<script setup>, nothing is public by default, sodefineExposeis the exception, not the rule
Quick example
<script setup lang="ts">
// Typed props - no import needed
const props = defineProps<{ title: string; count: number }>()
// Typed events
const emit = defineEmits<{ (e: 'update:count', value: number): void }>()
const localCount = ref(props.count)
function increment() {
localCount.value++
emit('update:count', localCount.value) // signals parent
}
// Expose only what the parent truly needs
defineExpose({ increment })
</script>The parent calls childRef.value.increment() via template ref. Everything else stays private.
Key difference from Options API
Options API declares props, emits, and expose as plain objects inside defineComponent. Compiler macros do the same thing at build time, but Vue's TypeScript plugin picks up the generic types and gives full inference in the template and in parent components. No runtime overhead difference between the two approaches.
When to use
- Parent passes data →
defineProps(always first in<script setup>) - Child notifies parent or supports v-model →
defineEmits - Parent needs to call a method on the child directly →
defineExpose(rare; prefer events for data flow) - Default values on optional typed props →
withDefaults(defineProps<...>(), { count: 0 })
How the compiler handles this
Vite's SFC plugin transforms macros during build. defineProps() becomes a validated __props binding; defineEmits() registers event validators in the component options; defineExpose() sets an exposeProxy on the instance so only listed properties are visible when a parent reads ref.value. Nothing ships to the browser as a function call named defineProps. It compiles away entirely.
Common mistakes
1. Array syntax without types
// Wrong - no TypeScript inference, complex shapes become `any`
defineProps(['title', 'count'])
// Right
defineProps<{ title: string; count: number }>()2. Array or object default as a literal, not a factory
// Wrong - all instances share one array reference
withDefaults(defineProps<{ items?: string[] }>(), {
items: []
})
// Right
withDefaults(defineProps<{ items?: string[] }>(), {
items: () => []
})Every instance gets its own array. The same bug existed in Vue 2's data option, and it still catches people off guard.
3. Mutating props directly
// Wrong - Vue warns in dev mode, reactivity skips the update
props.config.theme = 'dark'
// Right - emit the change
emit('update:config', { ...props.config, theme: 'dark' })Props are readonly. Mutating nested objects bypasses the reactivity system and triggers a Vue warning in development.
4. Exposing reactive state instead of methods
// Risky - parent can read AND write internal state
defineExpose({ count, data })
// Better - parent can only trigger actions
defineExpose({ increment, reset })Once you expose a ref, the parent can mutate it. That breaks encapsulation and makes bugs much harder to trace.
5. Forgetting withDefaults on optional typed props
// Wrong - optional prop is `undefined` at runtime if not passed
defineProps<{ count?: number }>()
// props.count → undefined, not 0
// Right
withDefaults(defineProps<{ count?: number }>(), { count: 0 })Real-world usage
- Element Plus:
defineEmitsforElTablerow selection events (update:selection) - Nuxt Content:
definePropsfor document props in Markdown-backed components - Modal components:
defineExpose({ open, close })so a parent can trigger the modal imperatively - Vitest tests: mock emitted events with
vi.fn()on the mounted component wrapper
Follow-up questions
Q: What is the TypeScript advantage of defineProps<{ foo: string }>()?
A: The generic form gives exact types to props.foo in the template and in the setup function. Runtime-only declaration via an array of strings leaves everything as any.
Q: How does defineEmits validate events?
A: At compile time, the TypeScript plugin checks that emit('eventName', payload) matches the declared signature. At runtime, Vue checks event names against the declared list and warns if something unexpected is emitted.
Q: When does defineExpose actually matter?
A: Only when a parent holds a template ref and needs to call a method or read a value imperatively. In <script setup>, the component instance is sealed by default, so without defineExpose the parent sees nothing on ref.value.
Q: What does defineModel (Vue 3.4+) replace?
A: It auto-generates defineProps({ modelValue }) plus defineEmits(['update:modelValue']). Instead of writing both macros for a v-model prop, you write const model = defineModel<string>() and bind v-model="model" in the template.
Examples
Basic: Button with a counter and v-model
<template>
<button @click="increment">{{ props.count }}</button>
</template>
<script setup lang="ts">
const props = defineProps<{ count: number }>()
const emit = defineEmits<{ (e: 'update:count', value: number): void }>()
function increment() {
emit('update:count', props.count + 1)
}
</script>The parent uses <CountButton v-model:count="total" />. The child never owns the count, it only signals that it should change.
Intermediate: Content editor with multiple events
<template>
<div class="editor">
<textarea v-model="content" />
<button @click="publish">Publish</button>
</div>
</template>
<script setup lang="ts">
interface Post { id: string; content: string; published: boolean }
const props = withDefaults(
defineProps<{ post: Post }>(),
{ post: () => ({ id: '', content: '', published: false }) }
)
const emit = defineEmits<{
(e: 'update:post', post: Post): void
(e: 'publish', id: string): void
}>()
const content = ref(props.post.content)
watch(content, (val) => emit('update:post', { ...props.post, content: val }))
function publish() {
emit('publish', props.post.id)
}
</script>The component owns the local textarea state but pushes every change up to the parent. This pattern appears in Nuxt Content-style page editors.
Advanced: Modal with defineExpose
<!-- Modal.vue -->
<template>
<div v-if="visible" class="modal">
<slot />
<button @click="close">Close</button>
</div>
</template>
<script setup lang="ts">
const visible = ref(false)
function open() { visible.value = true }
function close() { visible.value = false }
// Expose only actions, not the raw `visible` ref
defineExpose({ open, close })
</script><!-- Parent.vue -->
<script setup lang="ts">
import Modal from './Modal.vue'
const modal = ref<InstanceType<typeof Modal> | null>(null)
function showModal() {
modal.value?.open() // type-safe because of defineExpose
}
</script>
<template>
<Modal ref="modal">
<p>Content here</p>
</Modal>
<button @click="showModal">Open</button>
</template>The parent can call open and close but cannot read or write visible directly. That is the point of exposing methods instead of state.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.