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
<script setup lang="ts">basics- 5 patterns for generic defineProps
- defineEmits types + parameter constraints
- Type inference for ref / reactive / computed
- Component ref types (useTemplateRef)
- Volar extension configuration
- tsconfig.json best practices
- 5 common TS errors
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:
// ❌ 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
}
}
<!-- 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
<!-- 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>
<!-- 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:
- Runtime errors: 80% → 10% (caught at compile time)
- IDE completion: 95%+ accuracy
- Refactoring confidence: types protect you from breaking things
- Team collaboration: explicit interfaces, lower communication cost
3. TypeScript Basic Configuration
(1) script setup lang="ts"
(2) tsconfig.json Basics
{
"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"
]
}
(3) 5 Recommended Settings
{
"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
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:
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.
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
const props = defineProps<{
name: string
age: number
active: boolean
}>()
(2) Pattern 2: Optional + Defaults
// 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
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
items: User[]
config: Record<string, unknown>
}>()
(4) Pattern 4: Function Props
const props = defineProps<{
formatter: (value: number) => string
onChange: (value: string) => void
}>()
(5) Pattern 5: Generic Components
// 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
// 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
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
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
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
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 YourselfOutput:
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.
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
<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
// 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
# Install the "Vue - Official" extension (Volar) in VS Code
# Search for: Vue - Official
(2) Recommended settings.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)
// ✅ 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[] }>()
// ✅ 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: [] }>()
// ✅ ref<T> + type inference
import { ref } from 'vue'
const count = ref(0) // inferred as Ref<number>
const user = ref<User | null>(null)
Output:
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.
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
<script setup lang="ts" generic="T">, then defineProps<{ items: T[] }>(). Supported since Vue 3.3.const modelValue = defineModel<string>('modelValue', { default: '' }). The generic specifies the type.ref<HTMLInputElement | null>(null)."strict": true, "noImplicitAny": true, "strictNullChecks": true.npx vue-tsc --noEmit runs a type check without emitting JS.📖 Summary
<script setup lang="ts">is the standard way to write Vue 3 + TS- 5 defineProps patterns: basic / optional / complex / function / generic
- defineEmits types + parameter constraints keep events correct
- 5 recommended tsconfig options: strict / noImplicitAny / strictNullChecks and more
- useTemplateRef (Vue 3.5+) gives perfect type inference
- Volar is an essential VS Code extension
- 5 common mistakes: spelling / types / circular references
📝 Exercises
-
Basic (difficulty ⭐) Convert one component from JS to TS:
<script setup lang="ts">- Generic defineProps
- Typed defineEmits
- Confirm it compiles without errors
-
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
-
Challenge (difficulty ⭐⭐⭐) Build a complete "Vue 3 + TS" project:
- 5 stores (Pinia + TS)
- 5 composables (TS)
- 10 components (generics + type inference)
- Solutions for the 5 common TS errors
- vue-tsc type checking (CI/CD)
- A perfect Volar + IDE setup
- Automatically generated type docs (typedoc)