Every architectural decision downstream — type inference in templates, IDE autocompletion, tree-shaking granularity — is constrained by how the build pipeline is configured on day one. This module treats vite.config.ts and tsconfig.json as load-bearing architecture, not boilerplate to copy-paste and forget.
Modern Build Pipeline: Vite + TypeScript
Vite serves source files over native ESM during development — no bundling step exists until production build. TypeScript files are transpiled per-request via esbuild (not type-checked; type errors surface separately via vue-tsc or your editor's language server). This separation is deliberate: type-checking is slow and blocking type-checking on every HMR update would destroy the fast feedback loop Vite is built for.
npm create vite@latest enterprise-app -- --template vue-ts
cd enterprise-app
npm install
npm install -D vue-tsctsconfig.json: Strict Mode Is Not Optional
strict: true is a bundle of eight distinct flags (strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, useUnknownInCatchVariables). Enabling it after a project has grown is exponentially more painful than enabling it at initialization — every implicit any and every possibly-undefined access becomes a compile error simultaneously.
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["src/shared/*"],
"@features/*": ["src/features/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}| Flag | What It Catches | Why It's Non-Negotiable at Enterprise Scale |
|---|---|---|
| noUncheckedIndexedAccess | arr[i] and record[key] are typed T | undefined instead of bare T | Array/object index access is the single most common source of runtime undefined crashes in large codebases — this flag forces explicit handling at every call site |
| exactOptionalPropertyTypes | Distinguishes { x?: string } (key may be absent) from { x: string | undefined } (key present, value undefined) | Prevents accidentally writing obj.x = undefined where the type contract only allowed omitting the key entirely — critical for API payload shapes |
| verbatimModuleSyntax | Forces explicit import type for type-only imports | Guarantees the bundler can safely elide type imports without running a full type-aware analysis, keeping build times predictable as the codebase grows |
| isolatedModules | Disallows patterns that require whole-program type information to transpile a single file (e.g. re-exporting a type without export type) | Required because esbuild transpiles file-by-file with zero cross-file knowledge — this flag catches what would otherwise be a silent esbuild miscompile |
vite.config.ts: Path Aliases, Env Typing, and Build Targets
Path aliases must be declared in both tsconfig.json (for the type checker and IDE) and vite.config.ts (for the actual module resolution at build/dev time) — they are two independent systems that happen to need matching configuration.
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
vue({
script: {
defineModel: true,
propsDestructure: true
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url)),
'@features': fileURLToPath(new URL('./src/features', import.meta.url))
}
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
},
server: {
port: 5173,
proxy: {
'/api': {
target: env.VITE_API_PROXY_TARGET ?? 'http://localhost:8080',
changeOrigin: true
}
}
},
build: {
target: 'es2022',
sourcemap: mode !== 'production',
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia']
}
}
}
}
}
})Typed environment variables require augmenting Vite's ImportMetaEnv interface — without this, every import.meta.env.VITE_* access is typed as string | any with no autocomplete or typo protection.
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_API_PROXY_TARGET: string
readonly VITE_ENABLE_ANALYTICS: 'true' | 'false'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}SFC Anatomy: Block Order and Compilation Mechanics
A .vue file is not a runtime format — it's compiled away entirely. The Vue compiler splits an SFC into three logical blocks (<script setup>, <template>, <style>), each processed by a different sub-pipeline, then stitches the output back into a single ES module.
Compilation Pipeline for a Single SFC
- The
<template>block is compiled to arenderfunction, NOT interpreted at runtime — this happens entirely at build time via@vue/compiler-sfc - Bindings used in the template are statically analyzed against
<script setup>'s top-level scope, enabling the compiler to skip the render-context proxy that<script>(non-setup) requires <style scoped>blocks get a uniquedata-v-xxxxxxxxattribute injected onto every element the compiler sees in the paired template, and every CSS selector gets that attribute appended — this is why scoped styles cannot target dynamically rendered content from child components without:deep()- Vue 3.3+ optionally generates a hoisted script signature check, allowing HMR to skip a full component re-instantiation when only the
<template>changes and props/emits types are unchanged
<script setup lang="ts">
// 1. Imports
import { ref, computed } from 'vue'
// 2. Props / Emits (compiler macros — no import needed, globally available)
const props = defineProps<{ label: string }>()
const emit = defineEmits<{ change: [value: number] }>()
// 3. Reactive state
const count = ref(0)
// 4. Derived state
const doubled = computed(() => count.value * 2)
// 5. Methods
function increment(): void {
count.value++
emit('change', count.value)
}
</script>
<template>
<button type="button" @click="increment">
{{ props.label }}: {{ count }} (doubled: {{ doubled }})
</button>
</template>
<style scoped>
button {
padding: 0.5rem 1rem;
border: 1px solid #333;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
</style>Practical Example: Enterprise-Ready main.ts
The entry file is where cross-cutting concerns — global error handling, plugin registration order, environment-gated instrumentation — are wired once for the entire application. Registration order here is significant: Pinia must be installed before any store is used inside router guards, and the router must be installed before app.mount() so the initial navigation resolves before first paint.
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { installErrorTracking } from '@shared/monitoring/errorTracking'
const app = createApp(App)
// Pinia must be installed before router guards run, since guards
// commonly read auth state from a store (see Module 5).
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.config.errorHandler = (err, instance, info) => {
console.error(`[Vue Error] ${info}:`, err)
if (import.meta.env.PROD) {
installErrorTracking(err, instance, info)
}
}
app.config.warnHandler = (msg, instance, trace) => {
if (import.meta.env.DEV) {
console.warn(`[Vue Warn] ${msg}${trace}`)
}
}
router.isReady().then(() => {
app.mount('#app')
})Module 1 Checkpoint
Q1. Why does a successful `vite build` not guarantee your TypeScript types are correct?
Q2. What does `noUncheckedIndexedAccess` change about the type of `myArray[i]`?