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.

Syntaxnpx vue-tsc --noEmit # checks .ts files and .vue files, including templates

A 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

Example · vue
<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.

Example · bash
# 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 written

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

Example · ts
// 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.

Example · vue
<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

  • Be the first to comment on this lesson.