TypeScript code in an editor with type annotations and Vue SFC
8 min readVue 3 Migration

TypeScript During the Vue 3 Migration: Incremental Adoption That Doesn't Stall the Rewrite

“TypeScript first, then Vue 3” and “Vue 3 first, then TypeScript” both fail when they become competing programs. The workable pattern is parallel incrementalism: allowJs, a relaxed tsconfig path while moving, and new code (especially composables and Pinia stores) typed in TypeScript. Pair with ESLint + Prettier so the editor matches CI.

Turn on strict by directory or by package, not overnight on 800 files—unless you enjoy a six-month // @ts-expect-error graveyard that nobody removes.

1. Types follow refactors

When you touch a file for Vue 3, add types at the boundary. Leave untouched legacy in JS until a scheduled pass—velocity wins.

2. SFC and script setup

script setup and typed emits/props are easier in Vue 3; use that as a carrot for teams learning TS.

3. A pragmatic tsconfig.json for the migration

The mistake most teams make is copying a strict template from a greenfield Vue 3 project and pointing it at a five-year-old Vue 2 codebase. The compiler immediately reports thousands of errors, the team panics, and TypeScript adoption gets paused “until after the migration”—which never comes. Instead, start permissive and ratchet. Your day-one config should compile cleanly, even if it does very little real type checking yet.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "preserve",
    "strict": false,
    "noImplicitAny": false,
    "allowJs": true,
    "checkJs": false,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.js"]
}

From there, the ratchet has two knobs: strict for new directories (via references or per-package configs in a monorepo), and noImplicitAny globally once the new-code volume crosses ~30% of the SFCs you actually care about. Resist the urge to flip strictNullChecks globally on day one—nullability is the single biggest source of legacy noise in Vue 2 codebases that grew up without typing.

4. Typed composables and Pinia stores first

The highest leverage TypeScript work during a Vue 3 migration is not in the templates, it’s in the shapes that flow between layers: composables, stores, and the data fetched from your backend. A typed useUser() or a typed Pinia store catches refactor mistakes across dozens of consumer components without anyone editing those components. Treat composables as the public API of your app.

// src/composables/useInvoice.ts
import { ref, computed } from 'vue'
import type { Invoice, InvoiceStatus } from '@/types/billing'

export function useInvoice(id: string) {
  const invoice = ref<Invoice | null>(null)
  const error = ref<Error | null>(null)

  const isOverdue = computed(() =>
    invoice.value?.status === 'open' &&
    invoice.value.dueDate < new Date()
  )

  async function load(): Promise<void> {
    try { invoice.value = await fetchInvoice(id) }
    catch (e) { error.value = e as Error }
  }

  return { invoice, error, isOverdue, load }
}

When you migrate the corresponding component, IntelliSense already knows the shape. Combined with a typed Pinia store, the compiler enforces the contract that used to live only in code review comments. This is also the most natural moment to put real types around your Vue Router 4 route names and meta fields.

5. Shims, declarations, and the .d.ts graveyard

Two declarations buy you most of the comfort you need on day one. The SFC shim and a globals shim. Add them and forget them.

// src/shims-vue.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

// src/shims-globals.d.ts
declare global {
  interface Window { __APP_CONFIG__?: { apiBase: string } }
}
export {}

What you should not do is generate one .d.ts per legacy file with hand-written anys. That graveyard never gets cleaned up. If a third-party package has no types, prefer a single project-wide vendor.d.ts with narrow declarations for the symbols you actually call.

6. The strict-mode ratchet

Once new code is consistently typed, tighten the screws on a published schedule. The ratchet should be a CI rule, not a heroic refactor.

StageFlag flippedTypical noiseTrigger
BootstrapallowJs, skipLibCheck~0 errorsDay 1
SurfacenoImplicitAny100–500After composables typed
ReactivitystrictNullChecksHighestAfter Pinia migration
Polishstrict: trueTrailingVue 3 cutover done

Pair this with the broader sequencing in our Vue 3 migration checklist and budget the strict pass against your migration timeline—it almost always lands after the framework cutover, not before.

7. Anti-patterns we see weekly

  • Big-bang strict. Turning on strict: true across an 800-file Vue 2 app on a Friday. Monday brings a 14k-error backlog and a stalled Vue 3 program.
  • The @ts-ignore graveyard. Drive-by suppressions with no owner, no date, and no link to a ticket. Use @ts-expect-error with a comment instead so the compiler tells you when the suppression is no longer needed.
  • Typing the templates first. Templates are the worst place to start—they’re where Volar inference is most generous already. Start at the data boundary.
  • Shipping any as a return type. A composable that returns any poisons every caller. If you’re not sure of the shape yet, return unknown and force callers to narrow.
  • Two parallel programs. A “TypeScript squad” and a “Vue 3 squad” that never coordinate. The work is the same work. One backlog, one priority order.

8. FAQ

Should we adopt TypeScript before or after the Vue 3 cutover?

Neither sequentially. Adopt it during, scoped to new code. A team that finishes Vue 3 first and then “does TypeScript” spends another six months touching files they just touched. A team that does TypeScript first delays the Vue 3 program for a benefit nobody outside engineering can see.

Do we need TypeScript to use script setup?

No, but script setup is the most attractive entry point because typed defineProps and defineEmits are noticeably better than the runtime equivalents. See our Composition API vs Options API guide for the migration patterns.

What about Volar and the IDE story?

Volar (Vue Language Tools) is non-negotiable for SFC inference. Configure it consistently across the team, then make sure your ESLint and Prettier rules match what CI enforces. Editor green + CI red is a productivity tax you don’t need.

How do we keep the team from regressing?

A simple CI check that the count of @ts-expect-error and any in src/ never increases compared to main. It costs ten lines of script and replaces months of code-review nagging.

9. Migration checklist for TypeScript on a Vue 3 program

  • Permissive tsconfig.json compiles green on day one with allowJs.
  • SFC shim and globals shim committed; no per-file .d.ts graveyard.
  • All new composables and Pinia stores authored in .ts.
  • Backend response types generated (OpenAPI, GraphQL codegen) and imported, not hand-written.
  • Volar configured at the team level; ESLint/Prettier match CI.
  • CI ratchet on any and @ts-expect-error counts.
  • Strict-flag schedule published in the migration plan, not improvised at PR time.
  • Strict pass scheduled after the Vue 3 cutover, with a budgeted sprint.

If TypeScript and Vue 3 keep colliding on your roadmap, it’s usually a scope freeze conversation rather than a technical one.

Conclusion

TypeScript should accelerate the migration by catching breakages, not by becoming a second rewrite. Ratchet, celebrate typed new code, and schedule debt removal like any other backlog work.

Related