Skip to main content

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>() and defineEmits<T>() for type-safe component APIs
  • Choose ref for primitives and reassignable values, reactive for 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 .value is 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.