TypeScript in a Vue Project
Turn on TypeScript per file with lang="ts", check what Vite never checks using vue-tsc, and get the errors in your editor.
npx vue-tsc --noEmit # checks .ts files and .vue files, including templatesA single-file component is a .vue file holding a script block, a template and styles. TypeScript is opt-in per file: write <script setup lang="ts"> and that block is TypeScript.
The bundler does not check
Vite 8 transpiles TypeScript with the Oxc transformer, one file at a time (Vite 7 and earlier used esbuild). It deletes the annotations and emits JavaScript. It never verifies them, because checking needs the whole module graph and a single-file transform cannot see it. So a dev server that starts, and a build that succeeds, both prove nothing about types.
vue-tsc checks
vue-tsc is a wrapper around tsc, TypeScript's own command line compiler. It takes the same flags and additionally understands .vue files, so it checks expressions inside <template> and the props you pass to a child component, not only script. It is a separate pass: run npx vue-tsc --noEmit, or the type-check script that npm create vue@latest adds, and run it in CI.
The editor is the payoff
The Vue - Official extension (previously named Volar) uses the same engine, so an error appears as you type: a misspelt prop, a ref read without .value, a possibly-null value in a template. If Vetur is still installed, disable it in Vue 3 projects.
What trips people up
Types are erased at build time. Nothing at runtime enforces them, so data arriving from outside the app is whatever the sender actually sent, whatever the annotation claims.
Example
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Invoice {
id: number
amountCents: number
paid: boolean
}
const invoices = ref<Invoice[]>([
{ id: 1, amountCents: 4500, paid: true },
{ id: 2, amountCents: 12000, paid: false }
])
const outstandingCents = computed(() =>
invoices.value
.filter((invoice) => !invoice.paid)
.reduce((total, invoice) => total + invoice.amountCents, 0)
)
function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`
}
</script>
<template>
<ul>
<li v-for="invoice in invoices" :key="invoice.id">
{{ formatCents(invoice.amountCents) }}
<strong v-if="!invoice.paid">unpaid</strong>
</li>
</ul>
<p>Outstanding: {{ formatCents(outstandingCents) }}</p>
</template>
<style scoped>
strong {
color: #b00020;
}
</style>When to use it
- Adding TypeScript to one component inside an existing JavaScript Vue project.
- Catching a renamed prop or a changed interface in CI instead of in the browser.
- Setting up an editor so template expressions and child props are checked as you type.
- Explaining to a teammate why a green build still shipped a type error.
More examples
The two commands, and what each one does
These are different jobs, not alternatives. The dev server and the production bundle are transpilation only, so the type check has to be its own step. Run it before you push, and again in CI, or nothing enforces the types you wrote.
# run from the project root
npm run dev # Vite: strips types with Oxc, checks nothing
npm run type-check # the script create-vue adds, which runs vue-tsc
npx vue-tsc --noEmit # the same check without the script, no files writtenA shared types module
Types that more than one component needs belong in a plain .ts file, not in a component. vue-tsc checks these files too, so a change to the interface surfaces in every component that imports it. Reach for this as soon as a second component touches the same shape.
// src/types/invoice.ts
export interface Invoice {
id: number
amountCents: number
paid: boolean
}
export type InvoiceStatus = 'draft' | 'sent' | 'paid'
export function isOverdue(invoice: Invoice, daysOld: number): boolean {
return !invoice.paid && daysOld > 30
}Both import forms, and an error only vue-tsc sees
Two things worth recognising. First, `import type { X }` and `import { type X }` are the same thing: both tell the compiler the binding is types only, so the import disappears from the output. Second, the commented assignment is the whole argument for the separate check: the dev server accepts it, vue-tsc rejects it.
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Invoice, InvoiceStatus } from '@/types/invoice'
// Same thing, inline modifier:
// import { type Invoice, type InvoiceStatus } from '@/types/invoice'
const invoice = ref<Invoice>({ id: 1, amountCents: 999, paid: false })
const status = ref<InvoiceStatus>('draft')
const label = computed(() => (invoice.value.paid ? 'Paid' : status.value))
// Uncomment this line and npm run dev still compiles it:
// status.value = 'archived'
// vue-tsc: Type '"archived"' is not assignable to type 'InvoiceStatus'.
</script>
<template>
<p>{{ label }}</p>
</template>
<style scoped>
p {
margin: 0;
}
</style>
Discussion