TypeScript in a Vue Codebase: Adoption Without the Rewrite

You don't have to convert the whole app to get real value from TypeScript. How we adopt it incrementally in Vue projects: props and API payloads first, vue-tsc in CI, strictness turned up one dial at a time.

TypeScript in a Vue codebase: adoption without the rewrite

On almost every Vue project that started as plain JavaScript, the same conversation comes up: do we switch to TypeScript? The framing is usually all or nothing, a rewrite of every file, and so the decision gets postponed until the next "why is this prop undefined in production" meeting. That framing is wrong. TypeScript adoption in a Vue app works file by file, and the files where typing pays off first are not the ones people expect.

We have done this a few times now. Here is the order that actually works.

Start at the boundaries, not from file A

A big-bang conversion fails predictably: hundreds of errors appear in a day, someone gets frustrated, the strictness goes back off. Incremental adoption starts where data crosses a border:

  • Props. defineProps takes a type argument in <script setup lang="ts">, and that one line catches a renamed or missing prop at build time instead of in the browser.
<script setup lang="ts">
import type { Invoice } from '@/types/api'

defineProps<{
  invoice: Invoice
  compact?: boolean
}>()
</script>
  • API payloads. Every backend response has a shape; the codebase just doesn't record it. Type the objects your app actually receives: what an invoice looks like, which states an order can be in. Even a loose interface buys autocomplete and rename safety:
export interface Invoice {
  id: number
  number: string
  status: 'draft' | 'sent' | 'paid'
  total_minor: number
  due_date: string | null
}
  • Composables and stores. These are the shared logic every component leans on. A wrong type here radiates everywhere, which is exactly what you want a type checker for.

Compile checks and strictness are separate dials

Two things get confused: whether the compiler processes a file, and how much it complains. Handle them separately. Add vue-tsc and keep allowJs on so plain .js files still build; convert files when you touch them anyway. Strictness is its own scale: noImplicitAny first (the cheapest big win), strictNullChecks last, because null handling churns through a whole codebase and is easier once the data types exist.

Make the check part of the build

A type check that only runs when someone remembers is a check that rots. Add it to the build:

{
  "scripts": {
    "typecheck": "vue-tsc --noEmit",
    "build": "npm run typecheck && vite build"
  }
}

Once a type error fails the build like a failing test, the types stop being documentation that drifts and start being a gate. This matters more than any config choice.

What not to type

Skip the parts where types add noise: static markup, one-off demo components, CSS-ish glue. And resist the as cast that silences an error you don't understand; a lying cast is worse than an honest any, because it converts a real problem into a hidden one. unknown with a narrow type guard beats any and beats a cast.

The payoff arrives at renames

The day-to-day value shows up in refactors. Rename a prop the compiler lists every file involved. Change a status string from sent to issued and the union type points at every switch statement that cared. In the codebases we keep typed this way, API and prop changes stopped being "modify and pray" sessions.

Full coverage isn't the goal. Typed boundaries, enforced in CI, and strictness turned up on your own schedule gets most of the benefit before any file formally "becomes TypeScript", and none of it needs a rewrite or a freeze on feature work.