Skip to main content

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: defineProps is the door lock (what comes in), defineEmits is the doorbell (what signals out), defineExpose opens a specific window (what an outsider can reach)
  • Main difference from Options API: these macros give TypeScript inference automatically, no manual props: {} object needed
  • defineProps when a parent passes data, defineEmits for any event or v-model, defineExpose only when a parent needs to call a method imperatively
  • In <script setup>, nothing is public by default, so defineExpose is the exception, not the rule

Quick example

vue
<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

ts
// 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

ts
// 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

ts
// 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

ts
// 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

ts
// 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: defineEmits for ElTable row selection events (update:selection)
  • Nuxt Content: defineProps for 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

vue
<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

vue
<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

vue
<!-- 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>
vue
<!-- 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?