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.

terminalbash
npm create vite@latest enterprise-app -- --template vue-ts
cd enterprise-app
npm install
npm install -D vue-tsc

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

tsconfig.jsonjson
{
  "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"]
}
FlagWhat It CatchesWhy It's Non-Negotiable at Enterprise Scale
noUncheckedIndexedAccessarr[i] and record[key] are typed T | undefined instead of bare TArray/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
exactOptionalPropertyTypesDistinguishes { 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
verbatimModuleSyntaxForces explicit import type for type-only importsGuarantees the bundler can safely elide type imports without running a full type-aware analysis, keeping build times predictable as the codebase grows
isolatedModulesDisallows 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.

vite.config.tstypescript
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.

src/vite-env.d.tstypescript
/// <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 a render function, 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 unique data-v-xxxxxxxx attribute 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
AnatomyExample.vuevue
<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.

src/main.tstypescript
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

  1. Q1. Why does a successful `vite build` not guarantee your TypeScript types are correct?

  2. Q2. What does `noUncheckedIndexedAccess` change about the type of `myArray[i]`?