Vue.js TypeScript: Strongly Typed Applications
Vue.js and TypeScript have become one of the most popular pairings in modern frontend development. While Vue's gentle learning curve makes it approachable for beginners, adding TypeScript transforms it into a robust platform for building large, maintainable applications. This tutorial walks you through everything you need to know to build strongly typed Vue applications, from project setup to advanced patterns.
What Is Vue.js with TypeScript?
Vue.js is a progressive JavaScript framework for building user interfaces. TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript. When combined, TypeScript provides compile-time type checking, intelligent autocompletion, and safer refactoring for Vue components, composables, stores, and APIs.
Since Vue 3 was rewritten in TypeScript, the framework ships with first-class type definitions out of the box. The Composition API, in particular, was designed with type inference in mind, making it the recommended approach for TypeScript projects.
Why It Matters
- Catch errors early: Type errors surface during development rather than at runtime.
- Better developer experience: IDEs like VS Code provide precise autocompletion and inline documentation.
- Self-documenting code: Types serve as living documentation for component props, emits, and function signatures.
- Confident refactoring: The compiler tells you exactly what breaks when you change a shape or interface.
- Scalability: Large teams benefit from enforced contracts between modules.
Setting Up a Typed Vue Project
The easiest way to start is with Vite's Vue + TypeScript template. This scaffolds a project preconfigured with vue-tsc, the official type checker for Vue single-file components.
npm create vite@latest my-typed-app -- --template vue-ts
cd my-typed-app
npm install
npm run dev
The generated tsconfig.json includes the essential compiler options. Make sure strict is enabled to get the full benefit of TypeScript's safety guarantees.
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
You also need a shim file so TypeScript understands .vue imports. Vite's template includes src/env.d.ts:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
Typing Component Props with defineProps
With <script setup>, the defineProps macro accepts a generic type argument that fully describes your props. This is the recommended approach because it provides complete type inference without runtime overhead.
<script setup lang="ts">
interface User {
id: number
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
}
const props = defineProps<{
user: User
isLoading?: boolean
maxItems?: number
}>()
// Destructure with defaults using withDefaults
const { isLoading = false, maxItems = 10 } = withDefaults(
defineProps<{
user: User
isLoading?: boolean
maxItems?: number
}>(),
{
isLoading: false,
maxItems: 10
}
)
</script>
Note that withDefaults must be used at the top level of <script setup> and cannot be combined with destructured props in the same declaration. Pick one pattern and stick with it.
Typing Emits with defineEmits
Emits are typed by providing a type that maps event names to their payload signatures. This gives you autocomplete on event names and type checking on payloads.
<script setup lang="ts">
interface User {
id: number
name: string
}
const emit = defineEmits<{
(e: 'update', user: User): void
(e: 'delete', id: number): void
(e: 'cancel'): void
}>()
function handleSave(user: User) {
emit('update', user)
}
</script>
Typing Reactive State and Refs
Vue's reactivity primitives are fully generic. ref and reactive infer types automatically in most cases, but you can specify them explicitly when needed.
import { ref, reactive, computed } from 'vue'
// Inferred as Ref<number>
const count = ref(0)
// Explicit type for nullable or complex values
const user = ref<User | null>(null)
// reactive infers the shape
const state = reactive({
items: [] as string[],
loading: false
})
// Computed values infer their return type
const double = computed(() => count.value * 2)
// Explicit computed type when inference is ambiguous
const filtered = computed<User[]>(() => {
return users.value.filter(u => u.role === 'admin')
})
Building Typed Composables
Composables are where TypeScript shines. By typing inputs and outputs clearly, you create reusable logic that is impossible to misuse.
import { ref, onMounted, onUnmounted } from 'vue'
interface FetchResult<T> {
data: T | null
error: Error | null
loading: boolean
}
export function useFetch<T>(url: string) {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(true)
let controller: AbortController | null = null
async function execute() {
loading.value = true
error.value = null
controller = new AbortController()
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = (await response.json()) as T
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
}
onMounted(execute)
onUnmounted(() => controller?.abort())
return { data, error, loading, refresh: execute }
}
Using this composable in a component gives you full type safety on the returned data:
<script setup lang="ts">
import { useFetch } from './composables/useFetch'
interface Post {
id: number
title: string
body: string
}
const { data: posts, loading, error } = useFetch<Post[]>('/api/posts')
// posts.value is typed as Post[] | null
</script>
Typing Provide and Inject
Provide/inject can lose type safety if you are not careful. Vue provides InjectionKey to create a typed symbol that links the provider and consumer.
import type { InjectionKey, Ref } from 'vue'
import { provide, inject, ref } from 'vue'
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
// Provider
export function provideTheme() {
const theme = ref<string>('light')
provide(themeKey, theme)
return theme
}
// Consumer
export function useTheme() {
const theme = inject(themeKey)
if (!theme) {
throw new Error('useTheme must be used within a provider of themeKey')
}
return theme
}
Typing Pinia Stores
Pinia is the official state management library for Vue and has excellent TypeScript support. Define your store using the setup syntax for maximum type inference.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface Product {
id: number
name: string
price: number
}
export const useCartStore = defineStore('cart', () => {
const items = ref<Product[]>([])
const total = computed(() =>
items.value.reduce((sum, p) => sum + p.price, 0)
)
function add(product: Product) {
items.value.push(product)
}
function remove(id: number) {
items.value = items.value.filter(p => p.id !== id)
}
function clear() {
items.value = []
}
return { items, total, add, remove, clear }
})
Typing Template Refs
Template refs to DOM elements or child components can be typed explicitly to access their properties safely.
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'
const inputEl = ref<HTMLInputElement>()
const childRef = ref<InstanceType<typeof ChildComponent>>()
onMounted(() => {
inputEl.value?.focus()
childRef.value?.someExposedMethod()
})
</script>
<template>
<input ref="inputEl" type="text" />
<ChildComponent ref="childRef" />
</template>
Typing Route Components with Vue Router
Vue Router provides typed route params and query objects when you extend the RouteNamedMap interface. For simpler cases, you can cast using the built-in types.
import { useRoute } from 'vue-router'
import type { LocationQuery } from 'vue-router'
const route = useRoute()
// Access params with type assertion when needed
const userId = Number(route.params.id)
// Type query values explicitly
const search = (route.query.q as string | undefined) ?? ''
Best Practices
- Enable strict mode: Set
"strict": trueintsconfig.jsonand fix every warning. Do not suppress errors withanyunless absolutely necessary. - Prefer the Composition API: It was designed for TypeScript and offers far better inference than the Options API.
- Use
definePropsanddefineEmitswith generic syntax: Avoid the runtime declaration syntax in typed projects. - Define shared interfaces in dedicated files: Keep
types/orinterfaces/directories for domain models reused across components. - Type your composables' return values: Explicit return types prevent accidental shape changes from breaking consumers.
- Use
vue-tscin CI: Addvue-tsc --noEmitto your build or lint pipeline to catch type errors before deployment. - Avoid
any: Preferunknownwhen the type is genuinely uncertain, then narrow it with type guards. - Leverage
InstanceTypefor component refs: This keeps parent-child communication type-safe without manual interface duplication. - Use
defineModelfor two-way binding: In Vue 3.4+,defineModelis fully typed and simplifies v-model implementations.
Example: A Complete Typed Component
Putting it all together, here is a fully typed component that fetches, displays, and manages a list of users.
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
}
const props = withDefaults(defineProps<{
title?: string
pageSize?: number
}>(), {
title: 'User List',
pageSize: 10
})
const emit = defineEmits<{
(e: 'select', user: User): void
(e: 'error', message: string): void
}>()
const users = ref<User[]>([])
const loading = ref(false)
const errorMessage = ref<string | null>(null)
async function loadUsers() {
loading.value = true
errorMessage.value = null
try {
const res = await fetch('/api/users')
if (!res.ok) throw new Error(`Failed: ${res.status}`)
users.value = (await res.json()) as User[]
} catch (e) {
errorMessage.value = e instanceof Error ? e.message : 'Unknown error'
emit('error', errorMessage.value)
} finally {
loading.value = false
}
}
function handleSelect(user: User) {
emit('select', user)
}
onMounted(loadUsers)
</script>
<template>
<section>
<h2>{{ props.title }}</h2>
<p v-if="loading">Loading...</p>
<p v-else-if="errorMessage">{{ errorMessage }}</p>
<ul v-else>
<li
v-for="user in users.slice(0, props.pageSize)"
:key="user.id"
@click="handleSelect(user)"
>
{{ user.name }} ({{ user.role }})
</li>
</ul>
</section>
</template>
Conclusion
Combining Vue.js with TypeScript gives you the best of both worlds: Vue's intuitive reactivity model and TypeScript's compile-time safety net. By using the Composition API, typing your props and emits with generic syntax, building strongly typed composables, and leveraging tools like vue-tsc in your CI pipeline, you can build applications that scale gracefully while remaining pleasant to maintain. Start small by typing new components and gradually migrate existing ones, and your codebase will become more predictable, self-documenting, and resilient to change over time.