servers parse a clean module boundary instead of scanning nested function scopes for type definitions.
This finding matters because it shifts migration from a manual, error-prone process to a deterministic compiler transformation. Teams can preserve the original type contract, maintain strict visibility rules, and accelerate incremental adoption without sacrificing runtime performance or developer experience.
Core Solution
Implementing static type hoisting requires a compiler pipeline that separates static analysis from runtime generation. The process follows a deterministic sequence: parse, classify, extract, and reconstruct.
Step 1: AST Traversal and Node Classification
The compiler parses the Vue SFC and builds an AST. It iterates through the <script setup> block, classifying each node by its semantic role. Nodes are tagged as either RUNTIME (hooks, state, JSX, functions) or STATIC (interface, type, enum, import type). The classification relies on TypeScript's node kind enumeration and scope boundary detection.
Step 2: Scope Boundary Analysis
Top-level declarations are identified by checking their parent node. If a type node's immediate parent is the script block itself (not a function, arrow expression, or block statement), it is marked for hoisting. Nested types inside functions or hooks are explicitly excluded to preserve original scoping rules.
Step 3: Extraction and Module Reconstruction
Static nodes are extracted into a separate buffer. The compiler generates a clean React component wrapper, injecting only runtime logic. The extracted types are prepended to the output file, preserving original export modifiers and visibility.
Step 4: Import/Export Resolution
The compiler resolves cross-file dependencies. If a hoisted type is exported, the output maintains the export keyword. If it is consumed internally, it remains module-scoped. Import statements are deduplicated and sorted to prevent circular references.
Input: Vue 3 SFC
<script setup lang="ts">
import { ref, computed } from 'vue'
export interface CheckoutPayload {
cartId: string
currency: string
lineItems: number
}
enum DiscountTier {
NONE = 0,
STANDARD = 10,
PREMIUM = 25
}
type CartSummary = {
subtotal: number
appliedDiscount: DiscountTier
}
function applyDiscount(summary: CartSummary): CartSummary {
const discountAmount = summary.subtotal * (summary.appliedDiscount / 100)
return { ...summary, subtotal: summary.subtotal - discountAmount }
}
const payload = ref<CheckoutPayload>({
cartId: 'c_9f2a',
currency: 'USD',
lineItems: 3
})
const summary = computed<CartSummary>(() => ({
subtotal: 150.00,
appliedDiscount: DiscountTier.STANDARD
}))
</script>
Output: Compiled React TSX
import { useState, useMemo } from 'react'
export interface CheckoutPayload {
cartId: string
currency: string
lineItems: number
}
export enum DiscountTier {
NONE = 0,
STANDARD = 10,
PREMIUM = 25
}
export type CartSummary = {
subtotal: number
appliedDiscount: DiscountTier
}
function applyDiscount(summary: CartSummary): CartSummary {
const discountAmount = summary.subtotal * (summary.appliedDiscount / 100)
return { ...summary, subtotal: summary.subtotal - discountAmount }
}
export const CheckoutModule = useMemo(() => {
const [payload, setPayload] = useState<CheckoutPayload>({
cartId: 'c_9f2a',
currency: 'USD',
lineItems: 3
})
const summary = useMemo<CartSummary>(() => ({
subtotal: 150.00,
appliedDiscount: DiscountTier.STANDARD
}), [])
return { payload, summary, applyDiscount }
}, [])
Architecture Decisions and Rationale
Why hoist instead of inline?
React components are functions. Injecting type declarations inside the function body creates unnecessary closure scope. The JavaScript engine does not execute types, but the parser still allocates memory for the closure environment. Hoisting eliminates this overhead and aligns with React's module-first architecture.
Why preserve export modifiers?
Vue SFCs frequently export types for sibling components or API layers. Stripping export breaks downstream imports. The compiler retains visibility modifiers to maintain the original module contract.
Why exclude nested types?
Types declared inside functions are intentionally scoped to that execution context. Hoisting them would leak internal implementation details and cause naming collisions. The compiler respects lexical scoping by only extracting top-level nodes.
Why separate static and runtime buffers?
Mixing static and runtime code during generation increases parser complexity and raises the risk of syntax errors. Dual-buffer reconstruction ensures deterministic output and simplifies source map generation.
Pitfall Guide
1. Closure Pollution from Inline Types
Explanation: Compilers that inject interface or enum inside the React function body force the JavaScript engine to allocate closure space for static constructs. This increases memory footprint and degrades hot module replacement performance.
Fix: Enforce strict AST classification. Only nodes with the script block as their direct parent qualify for hoisting. Validate output with a scope analyzer before emitting.
2. Export Visibility Mismatch
Explanation: Stripping export from hoisted types breaks cross-file imports. Downstream modules fail to resolve the type, causing TypeScript compilation errors.
Fix: Preserve the original export keyword during extraction. If the source uses export type, the output must mirror it. Run a visibility audit pass before final emission.
3. Enum Runtime Leakage
Explanation: TypeScript enums compile to JavaScript objects at runtime. Hoisting them without marking them as const or enum can cause bundlers to include unnecessary runtime code.
Fix: Use const enum for pure lookup tables, or ensure the compiler emits standard enums outside the component. Validate bundler output with a tree-shaking analyzer.
4. Circular Dependency in Hoisted Types
Explanation: Extracting types without resolving import order can create circular references. Module A imports a type from Module B, which imports a type from Module A, causing runtime initialization failures.
Fix: Implement a dependency graph resolver. Sort hoisted types by import depth. Use import type syntax to break circular runtime dependencies.
5. Over-Hoisting Nested Declarations
Explanation: Aggressive extraction pulls types from inside functions, hooks, or conditional blocks. This leaks internal implementation details and causes naming collisions across components.
Fix: Restrict hoisting to nodes where parent.kind === ScriptBlock. Add a depth limiter to prevent traversal into function bodies. Test with nested scope fixtures.
6. Missing Type-Only Imports
Explanation: Failing to convert import { Type } to import type { Type } causes bundlers to include runtime modules that should be erased.
Fix: Run a post-extraction pass that converts type imports to import type. Validate with tsc --noEmit to ensure no runtime side effects remain.
7. IDE Cache Invalidation
Explanation: After migration, language servers may cache stale type boundaries. Developers experience delayed autocomplete, false errors, or missing definitions.
Fix: Clear TypeScript server cache (tsserver --shutdown). Regenerate tsconfig.json with explicit include paths. Restart the language server after compilation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Legacy Vue 2 codebase with minimal types | Manual refactoring | Low type density makes automated hoisting overhead unjustified | High labor, low tooling cost |
| Vue 3 SFCs with heavy type contracts | Static hoisting compiler | Preserves module boundaries, reduces migration time by 60-80% | Low labor, moderate tooling setup |
| Mixed Vue/React monorepo | Hybrid pipeline | Automated hoisting for new modules, manual review for shared types | Balanced, scalable |
| Performance-critical micro-frontends | Strict hoisting + const enums | Eliminates runtime enum objects, optimizes bundle size | High initial config, long-term savings |
Configuration Template
// vureact.config.ts
import { defineConfig } from 'vureact/compiler'
export default defineConfig({
input: 'src/**/*.vue',
output: 'dist/react',
typescript: {
hoistTopLevelDeclarations: true,
preserveExportModifiers: true,
convertTypeImports: true,
maxNestingDepth: 0 // Only extract direct script children
},
runtime: {
target: 'react-18',
stateStrategy: 'useState',
computedStrategy: 'useMemo',
lifecycleStrategy: 'useEffect'
},
validation: {
runTscCheck: true,
treeShakeAnalysis: true,
scopeBoundaryAudit: true
}
})
Quick Start Guide
- Install the compiler toolchain: Run
npm install vureact @vureact/compiler --save-dev in your project root.
- Initialize configuration: Execute
npx vureact init to generate vureact.config.ts with default hoisting and runtime settings.
- Run the migration pipeline: Execute
npx vureact compile --watch to process Vue SFCs and output React TSX files with hoisted types.
- Validate type boundaries: Run
npx tsc --noEmit and npx vite build --mode analyze to confirm zero runtime overhead and correct module resolution.
- Integrate into CI/CD: Add the compile and validation steps to your pipeline. Block merges if scope boundary audits fail or tree-shaking efficiency drops below 90%.