Chapter 22

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

  1. Compile-time transformation, not a runtime feature: <script setup> is processed by @vue/compiler-sfc at build time, converting its contents into a standard setup() function. The generated code is functionally identical to manually written Composition API at runtime โ€” zero additional runtime overhead.

  2. 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.

  3. Type declaration trades runtime validation for TypeScript experience: defineProps<T>() gives TypeScript precise prop type inference but generates no validator. Runtime declaration has full validation but weaker type inference. Production applications should choose based on their needs, or combine both approaches.

  4. Closed by default: <script setup> exposes nothing to parent components: A ref to a <script setup> component returns an empty object by default. Use defineExpose() to explicitly declare what's visible to the outside โ€” achieving true encapsulation.

  5. Top-level await makes the component asynchronous: A <script setup> component using top-level await must be wrapped in <Suspense> in its parent. Without it, the component renders before the Promise resolves (with data as undefined), causing runtime errors.

Rate this chapter
4.5  / 5  (7 ratings)

๐Ÿ’ฌ Comments