How to Use Vue 3 with TypeScript
Vue's reactivity system is elegant until you lose type safety. Your ref() contains user data. The template renders it fine. But then you add a computed property and TypeScript complains about undefined properties. The reactivity works perfectly, but somewhere between the Composition API and your template, you've lost the type information that could've caught the mismatch.
TypeScript and Vue 3's Composition API work together to solve this, but only if you set them up correctly. With proper typing for refs, reactive objects, and props, your editor catches property mismatches before they reach the browser. Vue 3 was rebuilt in TypeScript to make type inference natural. No more wrestling with Vue.extend() or manual type annotations for every component option. This guide shows you how to build type-safe Vue applications, from project setup to advanced patterns with generics and discriminated unions. For framework comparisons, Svelte with TypeScript takes a compiler-first approach while React with TypeScript uses a virtual DOM model.
Starting a Vue 3 Project with TypeScript
The official way to create a Vue 3 project with TypeScript is create-vue, which sets up Vite with all the right configurations:
npm create vue@latest my-vue-app
When prompted, select "Yes" for TypeScript support. This creates a project with:
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router? … Yes (optional)
✔ Add Pinia? … Yes (optional, for state management)
The generated tsconfig.json is already configured for Vue:
{
"compilerOptions": {
"target": "ES2020",
"jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
The "jsx": "preserve" setting is important because Vue's template compiler handles JSX in .vue files, not the TypeScript compiler.
For your IDE, install the Vue - Official extension (previously called Volar) in VS Code. This gives you TypeScript support inside .vue files, including template type checking.
Typing Vue Components with Script Setup
Vue 3's <script setup> syntax is the recommended way to write components with TypeScript. It's more concise than the Options API and has better type inference:
<script setup lang="ts">
import { ref } from 'vue'
// TypeScript infers count as Ref<number>
const count = ref(0)
const increment = () => {
count.value++
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
The lang="ts" attribute enables TypeScript in the script block. Vue's compiler then type-checks both your script logic and template expressions. If you try to call count.toUpperCase() in the template, you'll get a type error because count is a number.
Why <script setup> over Options API? The Options API requires more type annotations and has weaker type inference. With <script setup>, TypeScript can infer most types automatically, and you get better autocomplete in your IDE.
Defining Props and Emits
Vue 3.3+ offers a clean syntax for typing props using interfaces. You can use either type-based or runtime declarations:
<script setup lang="ts">
interface UserProfile {
id: number
name: string
email: string
role: 'admin' | 'user' | 'guest'
}
interface Props {
user: UserProfile
isActive?: boolean // Optional prop
}
// Type-based declaration (recommended for TypeScript)
const props = defineProps<Props>()
// Now you can use props.user.name with full type safety
</script>
<template>
<div>
<h2>{{ props.user.name }}</h2>
<p>{{ props.user.email }}</p>
<span v-if="props.isActive">Active</span>
</div>
</template>
For props with default values, use withDefaults:
<script setup lang="ts">
interface Props {
title: string
count?: number
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
tags: () => [] // Use function for object/array defaults
})
</script>
Typing emits works similarly. Vue 3.3+ introduced a cleaner named tuple syntax:
<script setup lang="ts">
// Modern syntax (Vue 3.3+)
const emit = defineEmits<{
submit: [data: { email: string; password: string }]
cancel: [] // No parameters
update: [id: number, value: string]
}>()
// Usage
const handleSubmit = () => {
emit('submit', { email: 'user@example.com', password: 'secret' })
}
</script>
The older call signature syntax still works if you're on an earlier Vue version:
<script setup lang="ts">
// Older syntax (still valid)
const emit = defineEmits<{
(e: 'submit', data: { email: string; password: string }): void
(e: 'cancel'): void
}>()
</script>
Managing Reactive State with TypeScript
Vue offers two ways to create reactive state: ref and reactive. Knowing when to use each matters for writing clean TypeScript code.
Using ref for Primitives and Objects
ref works with any type and requires accessing the value through .value:
<script setup lang="ts">
import { ref } from 'vue'
// TypeScript infers the type from the initial value
const count = ref(0) // Ref<number>
const message = ref('Hello') // Ref<string>
// Explicit typing when the initial value doesn't match final type
const user = ref<UserProfile | null>(null)
// Later, after fetching from API
user.value = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'admin'
}
</script>
The .value is only needed in the script section. In templates, Vue automatically unwraps refs:
<template>
<!-- No .value needed here -->
<p>{{ message }}</p>
<p v-if="user">{{ user.name }}</p>
</template>
Using reactive for Objects
reactive only works with objects (including arrays, Maps, and Sets) but doesn't require .value:
<script setup lang="ts">
import { reactive } from 'vue'
interface FormData {
email: string
password: string
rememberMe: boolean
}
// Explicit type annotation
const form: FormData = reactive({
email: '',
password: '',
rememberMe: false
})
// Access properties directly, no .value
form.email = 'user@example.com'
</script>
Here's the gotcha: you can't reassign reactive objects, and destructuring breaks reactivity:
<script setup lang="ts">
const state = reactive({ count: 0 })
// This breaks reactivity!
let { count } = state
// This also doesn't work
state = reactive({ count: 1 }) // Error: can't reassign
// Use ref if you need reassignment
const stateRef = ref({ count: 0 })
stateRef.value = { count: 1 } // This works
</script>
When to use which? Use ref for primitives and when you need to reassign the entire value. Use reactive for complex objects where you'll only update properties, not replace the whole object. If you're unsure, ref is the safer default because it works everywhere.
Typing Computed Properties
Computed properties usually infer their type from the return value, but you can be explicit:
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
// Type is inferred as ComputedRef<number>
const doubled = computed(() => count.value * 2)
// Explicit type (useful for complex computations)
const formattedCount = computed<string>(() => {
return `Count: ${count.value}`
})
</script>
Typing Template Refs
Template refs need explicit types to access DOM element properties:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// Type the ref with the HTML element type
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
// TypeScript knows about HTMLInputElement methods
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputRef" type="text" />
</template>
For refs to child components, you'll need the component's exposed type (covered in the Advanced Patterns section).
Creating Reusable Composables
Composables are Vue's answer to React hooks. They let you extract and reuse stateful logic across components. TypeScript makes them even better with proper return type inference:
// composables/useFetch.ts
import { ref, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
refetch: () => Promise<void>
}
export function useFetch<T>(url: string): UseFetchReturn<T> {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
} finally {
loading.value = false
}
}
// Fetch on creation
fetchData()
return {
data,
error,
loading,
refetch: fetchData
}
}
Using it in a component:
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
interface Todo {
id: number
title: string
completed: boolean
}
// TypeScript infers the full return type
const { data, error, loading, refetch } = useFetch<Todo[]>(
'https://jsonplaceholder.typicode.com/todos'
)
</script>
<template>
<div>
<button @click="refetch">Refresh</button>
<p v-if="loading">Loading...</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<ul v-else-if="data">
<li v-for="todo in data" :key="todo.id">
{{ todo.title }}
</li>
</ul>
</div>
</template>
The power here is the generic <T> parameter. You can reuse useFetch with any data type, and TypeScript ensures type safety throughout.
For local state composables, you might want to return values with as const to preserve literal types:
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
// 'as const' makes the return type readonly and preserves method names
return {
count,
increment,
decrement,
reset
} as const
}
Advanced Component Patterns
Generic Components
Vue 3.3+ supports generic components. They're useful for building reusable UI components:
<!-- components/GenericList.vue -->
<script setup lang="ts" generic="T extends { id: number | string }">
interface Props {
items: T[]
renderKey?: keyof T
}
const props = defineProps<Props>()
const emit = defineEmits<{
select: [item: T]
}>()
</script>
<template>
<ul>
<li
v-for="item in items"
:key="item.id"
@click="emit('select', item)"
>
<slot :item="item">
{{ props.renderKey ? item[props.renderKey] : item.id }}
</slot>
</li>
</ul>
</template>
Using the generic component:
<script setup lang="ts">
import GenericList from '@/components/GenericList.vue'
interface Product {
id: number
name: string
price: number
}
const products: Product[] = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 }
]
const handleSelect = (product: Product) => {
console.log('Selected:', product.name)
}
</script>
<template>
<GenericList
:items="products"
render-key="name"
@select="handleSelect"
>
<template #default="{ item }">
<strong>{{ item.name }}</strong> - ${{ item.price }}
</template>
</GenericList>
</template>
Typing Provide and Inject
For sharing state across component trees, use provide and inject with InjectionKey for type safety:
// keys.ts
import { type InjectionKey, type Ref } from 'vue'
export interface AppTheme {
primaryColor: string
isDarkMode: boolean
}
// Create a typed injection key
export const themeKey: InjectionKey<Ref<AppTheme>> = Symbol('theme')
Provider component:
<script setup lang="ts">
import { ref, provide } from 'vue'
import { themeKey, type AppTheme } from '@/keys'
const theme = ref<AppTheme>({
primaryColor: '#3498db',
isDarkMode: false
})
// Provide with the typed key
provide(themeKey, theme)
</script>
<template>
<div>
<slot />
</div>
</template>
Consumer component:
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey } from '@/keys'
// TypeScript knows theme is Ref<AppTheme> | undefined
const theme = inject(themeKey)
if (!theme) {
throw new Error('Theme not provided!')
}
const toggleDarkMode = () => {
theme.value.isDarkMode = !theme.value.isDarkMode
}
</script>
Using Utility Types with Props
Utility types like Partial, Pick, and Omit can create prop variations:
<script setup lang="ts">
interface ButtonProps {
text: string
variant: 'primary' | 'secondary' | 'danger'
size: 'small' | 'medium' | 'large'
disabled?: boolean
onClick: () => void
}
// Component that uses only some button props
interface IconButtonProps extends Pick<ButtonProps, 'variant' | 'size' | 'onClick'> {
icon: string
}
// Component that makes all props optional for loading state
interface LoadingButtonProps extends Partial<ButtonProps> {
loading: boolean
}
</script>
Discriminated Unions for Component State
Discriminated unions prevent impossible states in your components:
<script setup lang="ts">
import { ref } from 'vue'
interface User {
id: number
name: string
email: string
}
// Define all possible states
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: T }
const userState = ref<FetchState<User>>({ status: 'idle' })
const fetchUser = async (id: number) => {
userState.value = { status: 'loading' }
try {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
userState.value = { status: 'success', data }
} catch (error) {
userState.value = {
status: 'error',
error: error instanceof Error ? error : new Error('Unknown error')
}
}
}
</script>
<template>
<div>
<p v-if="userState.status === 'idle'">Click to load user</p>
<p v-else-if="userState.status === 'loading'">Loading...</p>
<!-- TypeScript knows error exists here -->
<p v-else-if="userState.status === 'error'">
Error: {{ userState.error.message }}
</p>
<!-- TypeScript knows data exists here -->
<div v-else-if="userState.status === 'success'">
<h2>{{ userState.data.name }}</h2>
<p>{{ userState.data.email }}</p>
</div>
</div>
</template>
This pattern eliminates bugs like accessing data while still loading or checking error when the request succeeded.
Where Developers Get Stuck
Mistake 1: Using reactive() with Primitives
You can't use reactive with primitives - it only works with objects:
<script setup lang="ts">
import { reactive, ref } from 'vue'
// Wrong: This won't be reactive
const count = reactive(0) // TypeScript error!
// Correct: Use ref for primitives
const count = ref(0)
// Also correct: Wrap primitive in an object
const state = reactive({ count: 0 })
</script>
Mistake 2: Destructuring Reactive Objects
Destructuring a reactive object breaks reactivity:
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({
count: 0,
message: 'Hello'
})
// Wrong: count and message are no longer reactive
const { count, message } = state
// Correct: Access properties directly
console.log(state.count)
// Or use toRefs to destructure while keeping reactivity
import { toRefs } from 'vue'
const { count, message } = toRefs(state) // Now they're refs
</script>
Mistake 3: Forgetting .value in Script
A common mistake is forgetting to use .value when working with refs in the script section:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
// Wrong: This modifies the ref object itself, not the value
count++ // TypeScript error
// Correct: Access the value property
count.value++
</script>
Remember: .value is required in <script> but not in <template>.
Mistake 4: Wrong Event Types in Templates
Vue's event handling has specific types:
<script setup lang="ts">
// Wrong: Using generic Event type
const handleInput = (e: Event) => {
const value = e.target.value // TypeScript error: Property 'value' doesn't exist
}
// Correct: Use specific Vue event types
const handleInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value
}
// Even better: Let Vue infer from template
const handleInput = (e: Event) => {
if (e.target instanceof HTMLInputElement) {
const value = e.target.value // TypeScript knows this is safe
}
}
</script>
<template>
<input @input="handleInput" />
</template>
Mistake 5: Props Mutation
Props are readonly in Vue, but TypeScript won't always catch mutations:
<script setup lang="ts">
interface Props {
items: string[]
}
const props = defineProps<Props>()
// Wrong: Mutating props directly
props.items.push('new item') // This works at runtime but violates Vue principles
// Correct: Create local reactive copy
const localItems = ref([...props.items])
localItems.value.push('new item')
// Or emit an event to parent
const emit = defineEmits<{
addItem: [item: string]
}>()
const addItem = (item: string) => {
emit('addItem', item)
}
</script>
Mistake 6: v-model Type Issues
When creating custom components with v-model, make sure your types align:
<!-- CustomInput.vue -->
<script setup lang="ts">
// Wrong: Mismatched types
defineProps<{ modelValue: string }>()
defineEmits<{ 'update:modelValue': [value: number] }>() // Should be string!
// Correct: Types match
defineProps<{ modelValue: string }>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
Type-Safe State Management with Pinia
For global state management, Pinia is the official recommendation. It has strong TypeScript support out of the box. Pinia replaced Vuex because it offers better type inference and a simpler API. For server state and data fetching, consider TanStack Query for Vue, which provides type-safe async state management across frameworks.
Setting up a Pinia store:
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export const useUserStore = defineStore('user', () => {
// State
const currentUser = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Getters (computed)
const isAdmin = computed(() => currentUser.value?.role === 'admin')
const userName = computed(() => currentUser.value?.name ?? 'Guest')
// Actions
const login = async (email: string, password: string) => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
if (!response.ok) {
throw new Error('Login failed')
}
currentUser.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
isLoading.value = false
}
}
const logout = () => {
currentUser.value = null
error.value = null
}
return {
// State
currentUser,
isLoading,
error,
// Getters
isAdmin,
userName,
// Actions
login,
logout
}
})
Using the store in components:
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Destructure state/getters with storeToRefs to keep reactivity
const { currentUser, isAdmin, userName } = storeToRefs(userStore)
// Actions can be destructured directly (they're not reactive)
const { login, logout } = userStore
const handleLogin = async () => {
await login('user@example.com', 'password123')
}
</script>
<template>
<div>
<p v-if="currentUser">Welcome, {{ userName }}</p>
<p v-if="isAdmin">You have admin privileges</p>
<button v-if="!currentUser" @click="handleLogin">Login</button>
<button v-else @click="logout">Logout</button>
</div>
</template>
Pinia automatically infers types from your store definition. You get full autocomplete and type checking without extra configuration. This is a big improvement over Vuex, which required manual type definitions for getters, actions, and mutations.
Building Type-Safe Vue Applications
Vue 3 and TypeScript work together to catch errors before they reach production. Here are the key patterns to remember:
- Use
<script setup lang="ts">for better type inference and cleaner code - Leverage
defineProps<T>()anddefineEmits<T>()for type-safe component APIs - Choose
reffor primitives and reassignable values,reactivefor stable objects - Build reusable logic with composables and generics
- Use discriminated unions to eliminate impossible states
- Adopt Pinia for global state management instead of Vuex
- Remember that
.valueis required in scripts but not templates
The Vue ecosystem has adopted TypeScript. Vue 3 itself is written in TypeScript, and first-party libraries like Vue Router and Pinia are too. This means good documentation, strong community support, and tooling that just works.
For building full-stack applications with Vue and TypeScript, consider integrating with a type-safe backend like Convex, which provides end-to-end type safety from your database to your components.