TypeScript in a Vue project has two halves. One is the types you write: props, emits, refs, the return type of a composable. The other is the thing that actually checks them, and in a Vite project that thing is not Vite. If the only place your types are checked is your editor, a prop of the wrong type can reach production the moment someone commits from a different editor, or from the same editor with the file closed.
The fix is a command that fails on a type error. You will add it as a script, make VS Code agree with it, and run it in CI with a cache; a Nuxt project gets the same from one command. The versions are vue-tsc 3.0, the current line since July, and TypeScript 5.9.
Vite strips types but does not check them
When Vite serves or builds a .ts file or the script block of a .vue file, it hands it to esbuild, which removes the type annotations and emits JavaScript. esbuild does this one file at a time, in milliseconds, without looking at any other file. That is why Vite is fast, and it is also why it cannot check types: a type error is almost always a disagreement between two files, the one that defines a function and the one that calls it wrongly.
So npm run dev runs code with type errors in it, and vite build ships them; the Vite docs leave type checking to your editor and build process. Vue's own compiler does a little more for .vue files, enough to turn defineProps<{ max: number }>() into runtime props, but it does not check that the parent passes a number either. Checking is a separate job and needs a separate tool.
Add a type-check script with vue-tsc
That tool is vue-tsc, a wrapper around TypeScript's tsc that teaches it to read .vue files. It checks script blocks and templates: a prop of the wrong type, a required prop left out, an event handler that expects the wrong argument, a misspelled field on a v-for item, a slot prop used as the wrong type. A project scaffolded with create-vue 3.18 and the TypeScript option already has it wired up. These are the type-check lines of its package.json:
{
"scripts": {
"build": "run-p type-check \"build-only {@}\" --",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"devDependencies": {
"npm-run-all2": "^8.0.4",
"typescript": "~5.8.0",
"vue-tsc": "^3.0.4"
}
}
Run npm run type-check and you get either silence and exit code 0, or a list of errors with file, line and column and a non-zero exit code. The build script runs the check and the Vite build side by side with npm-run-all2, so a type error fails the build.
Two template mistakes pass by default, because Vue lets them fall through to the component's root element: a prop name the component does not declare, and a listener for an event it does not emit. To make them errors, add "vueCompilerOptions": { "strictTemplates": true } to tsconfig.app.json (shown in the next section), at the top level next to compilerOptions.
--build is TypeScript's project references mode. It is needed here because the root tsconfig.json contains no files of its own (next section). On a project with a single flat tsconfig.json, the equivalent script is vue-tsc --noEmit, where --noEmit means check only, write nothing.
The scaffold pins TypeScript at ~5.8.0. TypeScript 5.9 was published on 31 July and vue-tsc 3.0 accepts it, so raise the range to ~5.9.0 once you have read its release notes.
Why create-vue gives you three tsconfig files
Open the project and you will find tsconfig.json, tsconfig.app.json and tsconfig.node.json, plus tsconfig.vitest.json if you chose Vitest. The root one is short:
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.vitest.json" }
]
}
files: [] says "this config checks nothing itself". references lists the real configs, called projects, and vue-tsc --build checks each in turn.
The reason for the split is that the files in your repository run in different worlds. src/ runs in a browser and needs DOM types; vite.config.ts runs in Node and needs process and path; tests run in Node with a simulated DOM and need describe and expect. One config cannot say all three without lying somewhere.
tsconfig.app.json is the one that covers your components:
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}
It extends @vue/tsconfig/tsconfig.dom.json, the Vue team's shared base, includes everything under src/ except tests, and maps the @/ alias so TypeScript resolves the same paths Vite does. The other two follow the same pattern:
tsconfig.node.jsonextends@tsconfig/node22instead, includes only the config files (vite.config.*,vitest.config.*,playwright.config.*and friends) and setstypes: ["node"].tsconfig.vitest.jsonextends the app config, swaps the include to the test folders and addsnodeandjsdomtotypes.
Each project also names its own tsBuildInfoFile in node_modules/.tmp, and those files are what the CI job caches.
Keep strict mode and skipLibCheck on
The shared base is where the strictness lives. @vue/tsconfig 0.7, the version create-vue 3.18 installs, sets strict: true and noImplicitThis: true, plus the options a Vite project needs to agree with its bundler: moduleResolution: "Bundler", module: "ESNext", verbatimModuleSyntax: true (a type-only import must say import type, which keeps Vite from trying to bundle a type), jsx: "preserve", noEmit: true and skipLibCheck: true.
Resist the urge to switch strict off when the first errors appear. Most of those errors are places where the code would have run with undefined where a value was expected. If a legacy folder has too many, exclude the folder in tsconfig.app.json and shrink the exclusion over time; do not weaken the whole project.
If you want more than the default, the option worth adding first is noUncheckedIndexedAccess, which makes list[0] type as T | undefined and catches a whole family of empty-array bugs. Version 0.8 of @vue/tsconfig, published on 15 August, turns it on by default; create-vue 3.18 still pins 0.7, so add it yourself for now.
skipLibCheck: true tells TypeScript not to type-check declaration (.d.ts) files, most of which sit inside node_modules. Your code's use of a library is still checked in full; only the library's own declaration files are taken on trust. Turning it off makes the check slower and makes your build fail whenever two libraries disagree about a type, or a library's types need a newer TypeScript than yours. Leave it on. If a library's types are wrong for you, fix it in a declaration file of your own under src/, with declare module, rather than with @ts-ignore at every call site. Keep that file short: it is a .d.ts too, so mistakes inside it are skipped as well.
Make VS Code report what the script reports
The script is the source of truth; the editor should agree with it. In VS Code install the Vue (Official) extension, version 3. Since 3.0 in July it always runs in what used to be called hybrid mode: the Vue language server handles the template and the SFC structure, and VS Code's built-in TypeScript service handles the TypeScript, with a plugin that lets it see into .vue files. There is no takeover mode to enable and no built-in extension to disable; one extension is the whole setup.
One setting matters. VS Code ships its own copy of TypeScript, and it is often a different version from the one in your node_modules. With a .ts file open, run "TypeScript: Select TypeScript Version" from the command palette and choose "Use Workspace Version". Now the editor checks with the same compiler as npm run type-check.
VS Code saves that choice for you alone. To bring the team along, add two settings to the .vscode/settings.json that create-vue already made, next to its file-nesting settings, and commit it: the first tells VS Code where the project's TypeScript is, the second makes it offer that version to everyone who opens the project.
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}
Run the check in CI with a cache
A type check that only runs on laptops is a type check that gets skipped. Run it on every push:
name: CI
on: [push, pull_request]
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/cache@v4
with:
path: node_modules/.tmp
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: tsbuildinfo-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
- run: npm run type-check
The actions/cache step keeps node_modules/.tmp, where each project's .tsbuildinfo file lives, from one run to the next. It restores the files from the most recent earlier commit with the same lockfile (restore-keys matches on the prefix) and saves them again after the job. It comes after npm ci because npm ci deletes node_modules before it installs.
The cache only pays off with one option the scaffold leaves out. Without incremental, --build still writes a .tsbuildinfo file per project, but the file holds no per-file results, so every run checks every file again. Add "incremental": true to compilerOptions in tsconfig.app.json and tsconfig.node.json (tsconfig.vitest.json inherits it from the app config).
With it, the file records a hash of each source file, which files import which, and the errors found. The next run skips every unchanged file, even though a fresh checkout gives each one a new modification time. A push that touched three components re-checks those three, plus any file that depends on a type they changed, not all three hundred. A dependency change produces a new lockfile hash and a cold run, which is right, because new dependencies mean new types.
Run the job in parallel with the tests rather than inside the build step, where its output scrolls past. A type error should be the headline of a failed run.
Type check a Nuxt project with nuxt typecheck
Nuxt does not run vue-tsc during nuxt dev or nuxt build either, for the same speed reasons. The TypeScript guide tells you to install the two packages and run the command:
npm install --save-dev vue-tsc typescript
npx nuxt typecheck
nuxt typecheck regenerates the types in .nuxt and runs vue-tsc against the configs Nuxt generates. In Nuxt 4 those are project references too: .nuxt/tsconfig.app.json for app/, .nuxt/tsconfig.server.json for server/, .nuxt/tsconfig.node.json for nuxt.config.ts and modules, and .nuxt/tsconfig.shared.json for shared/. The split exists for the same reason as in the Vite project: each part is checked with the types of the place where it runs. Put npx nuxt typecheck in your CI job in place of npm run type-check.
If you want the check inside the build as well, add typescript: { typeCheck: true } to nuxt.config.ts. It makes nuxt build fail on a type error and slows nuxt dev down a little, since the check then runs in the dev server too. typeCheck: 'build' limits it to nuxt build. A separate CI step is still the better default: a type error then fails a step of its own instead of showing up as a few lines in the build log.