script setup Compilation: Macro Expansion, Top-Level Binding Exposure and Constraints
<script setup> is not a runtime feature — no browser code knows it exists. It is a complete AST transformation performed by the compiler at build time: every line you write in <script setup> is rewritten into a plain setup() function. Understanding this transformation explains why defineProps needs no import, why parent components can't access your internal methods by default, and why top-level await is legal.
Level 1 · What You Need to Know (1–3 Years Experience)
The Essence: A Compile-Time Transformation
<script setup> is a special mode activated when Vue 3's compiler processes .vue files. The compiler extracts, analyzes, and reorganizes the <script setup> content, ultimately generating a standard component object with a setup() function.
Original <script setup>:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() { count.value++ }
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
Compiler output (simplified):
import { ref, defineComponent } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
function increment() { count.value++ }
// All top-level bindings automatically exposed to the template
return { count, increment }
}
})
Key behavior: All variables, functions, and imported values at the top level of <script setup> are automatically exposed to the template — no manual return required.
Compiler Macros: Functions That Only Exist at Compile Time
defineProps, defineEmits, defineExpose, defineOptions, defineSlots, withDefaults — these functions do not exist at runtime. They are Compiler Macros — the compiler recognizes these specific function calls, extracts information from them, then completely removes the calls from the output.
This is why you don't need to import them:
<script setup>
// ✅ No import needed for defineProps
const props = defineProps({
title: String,
count: { type: Number, default: 0 }
})
</script>
Two Ways to Declare Props
Runtime declaration: With validation, supports required/default/validator
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
items: { type: Array as PropType<string[]>, default: () => [] },
status: {
type: String,
validator: (v: string) => ['active', 'inactive'].includes(v)
}
})
Type declaration (TypeScript): Type-safe, but no runtime validation
interface Props {
title: string
count?: number
items?: string[]
}
const props = defineProps<Props>()
// Equivalent behavior, but no runtime validator code generated
The two cannot be mixed:
// ❌ Error: cannot use both type parameter and runtime declaration
const props = defineProps<{ title: string }>({ count: Number })
withDefaults: Adding Defaults to Type Declarations
interface Props {
title: string
count?: number
tags?: string[]
config?: { theme: 'light' | 'dark' }
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
tags: () => [], // reference types must use factory functions
config: () => ({ theme: 'light' })
})
// props.count is now typed as number (not number | undefined)
defineEmits Type Declaration
// Object form (recommended): parameter names documented in types
const emit = defineEmits<{
change: [newVal: string, oldVal: string]
submit: [formData: FormData]
close: [] // no parameters
}>()
emit('change', 'new', 'old')
defineExpose: Explicitly Exposing to Parent Components
<script setup> exposes nothing to parent components by default — a ref to the component returns an empty object:
<!-- ChildComponent.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const internalMethod = () => console.log('internal')
// Only exposed content is visible to parent components
defineExpose({
count,
reset: () => { count.value = 0 }
})
// internalMethod is not visible
</script>
<!-- ParentComponent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent>>()
onMounted(() => {
console.log(childRef.value?.count) // Ref<number> — the exposed ref
childRef.value?.reset() // the exposed method
// childRef.value?.internalMethod() // undefined — not exposed
})
</script>
<template>
<ChildComponent ref="childRef" />
</template>
Level 2 · How It Actually Works (3–5 Years Experience)
Full Compilation Output Comparison
The same functionality written three ways, compared:
<script setup> (most concise)
<script setup lang="ts">
import { computed } from 'vue'
interface Props { title: string; count?: number }
const props = withDefaults(defineProps<Props>(), { count: 0 })
const emit = defineEmits<{ change: [value: number] }>()
const doubled = computed(() => props.count * 2)
function handleClick() { emit('change', props.count + 1) }
</script>
Compiler output:
import { computed, defineComponent } from 'vue'
export default defineComponent({
props: {
title: { type: String, required: true },
count: { type: Number, default: 0 }
},
emits: ['change'],
setup(props, { emit }) {
const doubled = computed(() => props.count * 2)
function handleClick() { emit('change', props.count + 1) }
return { doubled, handleClick }
}
})
Note: The compiler extracted runtime props definition from the TypeScript interface Props — required: true corresponds to non-optional fields, default: 0 comes from withDefaults.
The <script setup> Compilation Pipeline
Input: .vue file
│
▼
Vue compiler (@vue/compiler-sfc) parsing
│
├─ Parse <template> → AST
├─ Parse <script setup> → AST
└─ Parse <style> → CSS
<script setup> AST analysis:
│
├─ Identify compiler macro calls
│ ├─ defineProps() → extract props def, remove call
│ ├─ defineEmits() → extract emits def, remove call
│ ├─ defineExpose() → generate expose() call, remove macro
│ └─ defineOptions()→ merge into component options, remove call
│
├─ Collect top-level bindings
│ (all const/let/var/function/class/import)
│
└─ Generate setup() function
├─ body = <script setup> content (macros removed)
└─ return { ...all top-level bindings }
Final output: standard ESM component object
Top-Level await: Automatic Async Component
<script setup>
// Top-level await is valid
const data = await fetch('/api/data').then(r => r.json())
</script>
The compiler generates an async setup function:
export default {
async setup() {
const data = await fetch('/api/data').then(r => r.json())
return { data }
}
}
Components with top-level await automatically become async components and must be wrapped in <Suspense>.
defineOptions: Solving Component Name in <script setup>
Before Vue 3.3, setting a name for a <script setup> component required two separate script blocks:
<!-- Old approach (verbose) -->
<script>
export default { name: 'MyComponent', inheritAttrs: false }
</script>
<script setup>
// ...
</script>
Vue 3.3 introduced defineOptions:
<script setup>
defineOptions({
name: 'MyComponent',
inheritAttrs: false
})
</script>
defineModel(): Vue 3.4's New Macro
<!-- Old approach (before Vue 3.4) -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function update(val) {
emit('update:modelValue', val)
}
</script>
<!-- New approach (Vue 3.4+) -->
<script setup>
const model = defineModel<string>()
// model is a readable/writable Ref
// read: model.value
// write: model.value = 'new' (auto-emits update:modelValue)
</script>
Level 3 · Design Documents and Source Code (Senior Developers)
@vue/compiler-sfc Processing
<script setup> compilation is handled in packages/compiler-sfc/src/compileScript.ts:
// packages/compiler-sfc/src/compileScript.ts
export function compileScript(
sfc: SFCDescriptor,
options: SFCScriptCompileOptions,
): SFCScriptBlock {
const { script, scriptSetup, source, filename } = sfc
// Parse <script setup> into AST
const scriptSetupAst = parse(scriptSetup.content, {
plugins: ['typescript', ...],
sourceType: 'module',
})
// Walk AST, identify macro calls
for (const node of scriptSetupAst.body) {
if (isCallOf(node, DEFINE_PROPS)) {
genPropsFromSetup(node, ...) // Extract props, remove call
}
if (isCallOf(node, DEFINE_EMITS)) {
genEmitsFromSetup(node, ...) // Extract emits, remove call
}
if (isCallOf(node, DEFINE_EXPOSE)) {
genExposeFromSetup(node, ...) // Generate expose(), remove call
}
// ...
}
return { content: generateCode(...) }
}
How defineProps Type Extraction Works
When defineProps<Props>() uses type declaration, the compiler extracts runtime props config from the TypeScript types:
// packages/compiler-sfc/src/script/defineProps.ts
function resolveRuntimePropsFromType(
node: TSTypeLiteral | TSInterfaceBody,
): PropsDestructureDecl {
const props: Record<string, PropTypeData> = {}
for (const member of node.members) {
if (member.type === 'TSPropertySignature') {
const key = member.key.name
const isRequired = !member.optional // no ? modifier → required: true
// Infer runtime type from TypeScript type annotation
const runtimeType = inferRuntimeType(member.typeAnnotation?.typeAnnotation)
props[key] = { key, required: isRequired, type: runtimeType }
// runtimeType: String, Number, Boolean, Array, Object, Function...
}
}
return props
}
This is why type-declaration mode can produce required: true but cannot produce validator or precise default — those require runtime values that aren't present in type information.
withDefaults Type Merging
At the type level, withDefaults(defineProps<Props>(), defaults) uses:
// Vue type definitions
type DefineProps<T, Defaults extends Partial<T>> = Readonly<
Omit<T, keyof Defaults> & {
[K in keyof Defaults]-?: T[K] extends undefined
? Defaults[K]
: T[K]
}
>
For properties with defaults, TypeScript removes undefined (guaranteed to have a value). For properties without defaults, the original type is preserved. This is a pure compile-time transformation — defaults are merged into the generated props definition by the compiler.
Restrictions: Cannot Use Options API-Specific Features
Inside <script setup>, you cannot access $options, $listeners (deprecated), and other Options API exclusives:
<script setup>
// ❌ Not accessible
const options = getCurrentInstance()?.type.$options // undefined
// ❌ $listeners doesn't exist in Vue 3 (merged into $attrs)
</script>
Macros must be called at the top level — not inside nested functions or conditional statements:
<script setup>
// ❌ Error: macro calls must be at top level
function setup() {
const props = defineProps({ ... }) // SyntaxError from compiler
}
// ❌ Error: conditional macro call
if (import.meta.env.DEV) {
defineOptions({ name: 'DevComponent' }) // SyntaxError from compiler
}
</script>
Using Both <script> and <script setup>
Some scenarios require both blocks:
<!-- Options API config (like inheritAttrs) + Composition API -->
<script>
// Only for config that can't be expressed in <script setup>
export default {
inheritAttrs: false,
// name: 'MyComp' // Vue 3.3+ can use defineOptions() instead
}
</script>
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>
Both blocks are merged — the export default object from <script> is merged with the setup() function generated from <script setup>.
Level 4 · Edge Cases and Pitfalls (All Levels)
Pitfall 1: Type Declaration Has No Runtime Validation
// Runtime declaration: validates, shows browser console warnings
const props = defineProps({
status: {
type: String,
validator: (v: string) => ['a', 'b', 'c'].includes(v)
}
})
// Parent passes 'invalid': [Vue warn]: Invalid prop: custom validator...
// Type declaration: type-safe, but no runtime validation
const props = defineProps<{ status: 'a' | 'b' | 'c' }>()
// Parent passes 'invalid': no warning! TypeScript only checks at compile time
For scenarios requiring runtime validation (user input, API data), use runtime declaration or combine both approaches.
Pitfall 2: Reference Types in withDefaults Must Use Factory Functions
// ❌ Wrong: all component instances share the same array object!
const props = withDefaults(defineProps<{ tags?: string[] }>(), {
tags: [] // this [] is shared across multiple instances
})
// ✅ Correct: factory function ensures each instance has its own array
const props = withDefaults(defineProps<{ tags?: string[] }>(), {
tags: () => []
})
Same reason as why data() must be a function in Options API.
Pitfall 3: defineExpose Exposes the ref Object, Not Its Value
const count = ref(0)
defineExpose({ count })
When a parent accesses childRef.value.count, it gets the ref object itself, not its .value:
// ParentComponent.vue
const child = ref<InstanceType<typeof Child>>()
console.log(child.value?.count) // Ref<number>, not number
console.log(child.value?.count.value) // number — correct access
// But in templates, Vue's auto-unwrapping applies:
// {{ childRef.count }} correctly shows the number
This inconsistency between script context (no auto-unwrap) and template context (auto-unwrap) is a common source of confusion.
Pitfall 4: Top-Level await Requires Suspense
<!-- ❌ Uses top-level await but no Suspense wrapper in parent -->
<script setup>
const data = await fetch('/api').then(r => r.json())
</script>
<!-- Parent must wrap with Suspense -->
<template>
<Suspense>
<template #default>
<AsyncComponent /> <!-- component with top-level await -->
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
Without <Suspense>, the component renders before the Promise resolves (with data as undefined), causing rendering errors or unexpected behavior.
Pitfall 5: this is Undefined in <script setup>
<script setup> has no this — setup() is a plain function, not a method:
<script setup>
// ❌ this is undefined (strict mode) or global (non-strict)
console.log(this) // undefined
// ✅ Use getCurrentInstance() — only for dev/debug, not production code
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
</script>
Pitfall 6: Props Destructuring Before Vue 3.3
// Before Vue 3.3: destructuring props loses reactivity
const { title, count } = defineProps<{ title: string; count: number }>()
// title and count are static values — prop changes don't update them
// Must access through props object
const props = defineProps<{ title: string; count: number }>()
const doubled = computed(() => props.count * 2) // reactive — correct
Vue 3.3 introduced Reactive Props Destructure — destructured prop variables are automatically tracked when accessed:
// Vue 3.3+: destructured props remain reactive
const { title, count = 0 } = defineProps<{ title: string; count?: number }>()
// Default value can be set directly in destructuring (replaces withDefaults)
// Both title and count are reactive in the template
Chapter Summary
-
Compile-time transformation, not a runtime feature:
<script setup>is processed by@vue/compiler-sfcat build time, converting its contents into a standardsetup()function. The generated code is functionally identical to manually written Composition API at runtime — zero additional runtime overhead. -
Compiler macros don't exist at runtime:
defineProps,defineEmits,defineExpose,defineOptions,defineModel, and others are special markers that the compiler recognizes and processes. They require no import, and they cannot be seen in call stacks — they exist only in source code. -
Type declaration trades runtime validation for TypeScript experience:
defineProps<T>()gives TypeScript precise prop type inference but generates novalidator. Runtime declaration has full validation but weaker type inference. Production applications should choose based on their needs, or combine both approaches. -
Closed by default:
<script setup>exposes nothing to parent components: Arefto a<script setup>component returns an empty object by default. UsedefineExpose()to explicitly declare what's visible to the outside — achieving true encapsulation. -
Top-level await makes the component asynchronous: A
<script setup>component using top-levelawaitmust be wrapped in<Suspense>in its parent. Without it, the component renders before the Promise resolves (with data asundefined), causing runtime errors.