Vue.js: TypeScript Best Practices

Last updated: 2026-10-20

TypeScript is the standard for enterprise Vue 3 projects—it provides type safety, IDE auto-completion, and the confidence to refactor. In Vue 3.4+, <script setup lang="ts"> takes TypeScript integration to a new level: defineProps / defineEmits are inferred automatically and component refs are perfectly typed.

Mastering Vue + TypeScript is the key milestone on the road from beginner to senior developer. This lesson builds a complete Vue 3 + TS knowledge system for you.

1. What You'll Learn


2. A JS Project's "undefined is not a function" Nightmare

(1) Pain Point: 100 undefined errors in a JS project

Alice's admin dashboard used to be a JS project. Typical bugs:

JS
// ❌ Broken version: JS errors only surface at runtime
export default {
  props: {
    user: { type: Object, required: true }
    // Misspelled prop name: userName (no error)
    // Wrong prop type: no error
    // Misspelled emit event name: no error
  }
}
VUE
<!-- The parent passes userName, but the child declares user -->
<UserCard userName="Alice" />  <!-- ❌ Only discovered at runtime -->

<!-- Misspelled emit event name -->
<Child @updae="handler" />  <!-- ❌ Only discovered at runtime -->

100+ potential bugs only surface at runtime, which makes debugging expensive.

(2) The Vue 3 + TypeScript Fix

VUE
<!-- Child component: UserCard.vue -->
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

const props = defineProps<{
  user: User
  variant?: 'primary' | 'secondary'
}>()

const emit = defineEmits<{
  select: [userId: number]
  delete: [userId: number]
}>()
</script>
VUE
<!-- Used from the parent: errors at compile time -->
<UserCard :user="alice" />  <!-- ✅ Compile-time type checking -->
<UserCard @updae="handler" />  <!-- ❌ TS error: event does not exist -->

Every mistake is caught at compile time, with a red squiggle right in the IDE.

(3) Benefits

After adopting TypeScript:


3. TypeScript Basic Configuration

(1) script setup lang="ts"

(2) tsconfig.json Basics

JSON
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue"
  ]
}
JSON
{
  "compilerOptions": {
    // 1. Strict mode (always enable)
    "strict": true,
    
    // 2. No implicit any
    "noImplicitAny": true,
    
    // 3. Strict null checks
    "strictNullChecks": true,
    
    // 4. Strict function types
    "strictFunctionTypes": true,
    
    // 5. Strict bind / call / apply
    "strictBindCallApply": true
  }
}

▶ Example: 5 Vue 3 + TS Project Structures

TEXT
src/
├── components/        # Shared components
│   ├── UserCard.vue   # <script setup lang="ts">
│   └── BaseButton.vue
├── views/             # Page components
├── stores/            # Pinia (TS)
├── composables/       # Composables
├── types/             # Type definitions
│   ├── user.ts        # export interface User
│   ├── api.ts         # export interface ApiResponse<T>
│   └── index.ts       # Re-exports
├── utils/             # Utility functions
├── router/            # Router (TS)
├── App.vue
└── main.ts            # Entry point

Output:

TEXT
The tree is intended as a reference layout, not a rendered page.
Following it means component, view, store, composable, type, util, and router code each live in their own folder, types/index.ts re-exports User and ApiResponse<T> for a single import path, and main.ts (not main.js) is the entry point that creates the app.
TEXT
src/components/UserCard.vue → imports { User } from '@/types'
src/stores/*.ts             → Pinia stores typed with interfaces
src/types/index.ts          → export * from './user'; export * from './api'
src/main.ts                 → createApp(App).use(router).use(pinia).mount('#app')

4. defineProps Generics: 5 Patterns

(1) Pattern 1: Basic Types

TS
const props = defineProps<{
  name: string
  age: number
  active: boolean
}>()

(2) Pattern 2: Optional + Defaults

TS
// withDefaults supplies the defaults
const props = withDefaults(defineProps<{
  name: string
  age?: number
  variant?: 'primary' | 'secondary'
}>(), {
  age: 18,
  variant: 'primary'
})

(3) Pattern 3: Complex Objects / Arrays

TS
interface User {
  id: number
  name: string
  email: string
}

const props = defineProps<{
  user: User
  items: User[]
  config: Record<string, unknown>
}>()

(4) Pattern 4: Function Props

TS
const props = defineProps<{
  formatter: (value: number) => string
  onChange: (value: string) => void
}>()

(5) Pattern 5: Generic Components

TS
// Generic component: List<T> lets you specify the item type
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{
  items: T[]
  selected?: T
}>()
</script>

<!-- Usage -->
<List :items="users" />  <!-- T = User -->
<List :items="products" />  <!-- T = Product -->

5. defineEmits Types

(1) 5 Event Declarations

TS
// 1. Simple events
const emit = defineEmits<{
  click: []
  submit: []
}>()

// 2. Single parameter
const emit = defineEmits<{
  select: [id: number]
  delete: [id: number]
}>()

// 3. Multiple parameters
const emit = defineEmits<{
  change: [id: number, oldValue: string, newValue: string]
}>()

// 4. Optional parameters
const emit = defineEmits<{
  search: [query?: string]
  load: [id: number, options?: object]
}>()

// 5. void return value
const emit = defineEmits<{
  success: [data: object]
  error: [message: string]
}>()

(2) Emitting Events

TS
const emit = defineEmits<{
  select: [id: number]
  delete: [id: number]
}>()

// ✅ TypeScript checks the argument types
emit('select', 123)  // ✅ OK
emit('select', 'abc')  // ❌ TS error
emit('delete', 456)

(3) Using It in the Parent


6. ref / reactive / computed Types

(1) ref Type Inference

TS
const { ref } = Vue

// Inferred as Ref<number>
const count = ref(0)
count.value = 1  // ✅

// Inferred as Ref<string>
const name = ref('Alice')

// Inferred as Ref<number | undefined> (may be undefined)
const maybeNumber = ref<number>()
maybeNumber.value  // type: number | undefined

(2) reactive Type Inference

TS
const { reactive } = Vue

// Inferred automatically
const state = reactive({
  count: 0,
  user: { name: 'Alice', age: 25 }
})

state.count  // type: number
state.user.name  // type: string

// Explicit type
interface State {
  count: number
  items: string[]
}
const s = reactive<State>({
  count: 0,
  items: []
})

(3) computed Types

TS
const { ref, computed } = Vue

const count = ref(10)

// Inferred automatically: ComputedRef<number>
const double = computed(() => count.value * 2)

// Explicit type
const formatted = computed<string>(() => `Count: ${count.value}`)

▶ Example: CDN Vue 3 + Type Inference Demo

▶ Try it Yourself

Output:

TEXT
A single line of text plus a button.
The line starts as count: 0 and renders the empty-state placeholder for user because the value is null;
each click on +1 re-renders the same line with the counter incremented, while user stays null and keeps showing that placeholder.
TEXT
Before click:  count: 0 | user: (empty)
After click:   count: 1 | user: (empty)
After 3 more:  count: 4 | user: (empty)

Note: in a CDN setup, JSDoc comments provide the type hints. In production, use Vite + tsconfig.json + <script setup lang="ts">.


7. Component Ref Types (useTemplateRef)

(1) Vue 3.5+ useTemplateRef

VUE
<template>
  <input ref="usernameInput">
  <MyChart ref="chartComponent" :data="chartData" />
</template>

<script setup lang="ts">
const { useTemplateRef, onMounted } = Vue
import MyChart from './MyChart.vue'

// ✅ TS infers: Ref<HTMLInputElement | null>
const inputRef = useTemplateRef<HTMLInputElement>('usernameInput')

// ✅ Component instance type
const chartRef = useTemplateRef<InstanceType<typeof MyChart>>('chartComponent')

onMounted(() => {
  inputRef.value?.focus()  // TS autocompletion
  chartRef.value?.refresh()
})
</script>

(2) The Old Way in Vue 3.4 and Earlier

TS
// Old way: you must write the ref<> type yourself
const { ref, onMounted } = Vue
import MyChart from './MyChart.vue'

const inputRef = ref<HTMLInputElement | null>(null)
const chartRef = ref<InstanceType<typeof MyChart> | null>(null)

8. Volar Extension Configuration

(1) Installation

BASH
# Install the "Vue - Official" extension (Volar) in VS Code
# Search for: Vue - Official
JSON
{
  "vue.enabled.volar": true,
  "vue.compilerOptions.target": 3.4,
  "vue.complete.casing.tags": ["PascalCase", "snake_case"],
  
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.preferences.includePackageJsonAutoImports": "on",
  
  "editor.formatOnSave": true,
  "[vue]": {
    "editor.defaultFormatter": "Vue.volar"
  }
}

(3) 5 Volar Features

Feature Description
Type inference Perfect inference for props / emits / ref
Autocompletion Smart suggestions for component names / props / emits
Error checking Shows compile-time errors (e.g. wrong prop type)
Go to definition F12 jumps to the component definition
Refactoring support Renaming a prop updates every reference

9. Putting It Together: Typing a Component End to End

▶ Example: TypeScript defineProps + defineEmits (⚠️ requires Vite + ts)

⚠️ The code below must run in a Vite + TypeScript project; the CDN global build does not compile TS. It demonstrates the core APIs:

TS
// ✅ 5 defineProps patterns
// 1. Basic
defineProps<{ name: string }>()

// 2. Optional + default
withDefaults(defineProps<{ name?: string }>(), { name: 'Guest' })

// 3. Complex
defineProps<{ user: User; items: User[] }>()

// 4. Function
defineProps<{ onClick: () => void }>()

// 5. Generic (requires generic="T")
defineProps<{ items: T[] }>()
▶ Try it Yourself
TS
// ✅ 5 defineEmits event shapes
// 1. Simple
defineEmits<{ click: [] }>()

// 2. Single argument
defineEmits<{ select: [id: number] }>()

// 3. Multiple arguments
defineEmits<{ change: [old: string, new: string] }>()

// 4. Optional
defineEmits<{ search: [q?: string] }>()

// 5. void
defineEmits<{ done: [] }>()
TS
// ✅ ref<T> + type inference
import { ref } from 'vue'
const count = ref(0)             // inferred as Ref<number>
const user = ref<User | null>(null)

Output:

TEXT
These snippets are documentation of macro signatures and are not rendered on their own.
In a Vite + TS project, Volar reports a type error for any call that violates the declarations — for example emit('select', 'abc') or a prop passed with the wrong shape — and shows the inferred type (Ref<number>, Ref<User | null>) on hover.
TEXT
Volar hover / diagnostics:
count        → Ref<number>
user         → Ref<User | null>
defineProps<{ name?: string }>() + withDefaults → name: string (default 'Guest')
emit('select', 'abc') → ❌ Argument of type 'string' is not assignable to 'number'

(1) 📊 Quick Reference for 5 Common TS Errors

Error Symptom Fix
Property 'x' does not exist Typo or missing type Check the spelling, add the type
Argument of type 'X' is not assignable Type mismatch Check the argument types
Type 'X' is not assignable to type 'Y | null Strict null checks Add ! or ?
Cannot find module './X' Wrong path Check the import
Object is possibly 'undefined' Optional value Use optional chaining ?.

(2) 📊 5 Performance Comparisons

Pattern Type safety Performance Best for
JS ❌ ⭐⭐⭐⭐⭐ Prototypes / small projects
TS (basic) ⭐⭐⭐ ⭐⭐⭐⭐ General use
TS (strict) ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ Enterprise projects
TS + Volar ⭐⭐⭐⭐⭐ ⭐⭐⭐ Recommended
TS + tsc build ⭐⭐⭐⭐⭐ ⭐⭐⭐ Large projects

(3) 📊 Quick Reference for 5 Common Mistakes

Mistake Symptom Fix
Misspelled prop name Compile error Use "go to definition" in the IDE
Misspelled emit name Compile error Use defineEmits types
Failed type conversion Compile error Use a type assertion or generics
ref.value undefined Runtime Use optional chaining ?.
Circular type references Compile error Use type-only imports

❓ FAQ

Q Is Vue 3 + TS slower than JS?
A Type checking happens at compile time; at runtime there is no impact (types are erased). Volar compiles quickly, so the difference is imperceptible.
Q Generic defineProps vs runtime declaration?
A Generics are recommended. TypeScript infers the prop types and the IDE completes them perfectly. A runtime declaration requires you to hand-write validators.
Q How do I write a generic component?
A <script setup lang="ts" generic="T">, then defineProps<{ items: T[] }>(). Supported since Vue 3.3.
Q How do I type defineModel?
A const modelValue = defineModel<string>('modelValue', { default: '' }). The generic specifies the type.
Q Does useTemplateRef require Vue 3.5+?
A Yes. In Vue 3.4 and earlier, use ref<HTMLInputElement | null>(null).
Q Must strict mode be enabled in tsconfig.json?
A It is a must for enterprise projects. Beginners can start with it off and tighten gradually. Recommended: "strict": true, "noImplicitAny": true, "strictNullChecks": true.
Q What is vue-tsc?
A vue-tsc is the TypeScript checker for Vue projects (built by the Volar team). npx vue-tsc --noEmit runs a type check without emitting JS.
Q Can Vue 3 + TS be used with SSR?
A Yes. Nuxt 3 has built-in TypeScript support, and Pinia / Vue Router both support TS natively.

📖 Summary


📝 Exercises

  1. Basic (difficulty ⭐) Convert one component from JS to TS:

    • <script setup lang="ts">
    • Generic defineProps
    • Typed defineEmits
    • Confirm it compiles without errors
  2. Intermediate (difficulty ⭐⭐) Build a complete TS type system:

    • 5 interfaces (User / Product / Order / Category / Cart)
    • 5 components using generic defineProps
    • 5 components using typed defineEmits
    • tsconfig in strict mode
    • Volar configured
  3. Challenge (difficulty ⭐⭐⭐) Build a complete "Vue 3 + TS" project:

    1. 5 stores (Pinia + TS)
    2. 5 composables (TS)
    3. 10 components (generics + type inference)
    4. Solutions for the 5 common TS errors
    5. vue-tsc type checking (CI/CD)
    6. A perfect Volar + IDE setup
    7. Automatically generated type docs (typedoc)
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏