TypeScript Best Practices for Vue Applications
Essential TypeScript patterns and practices for building type-safe Vue applications
TypeScript Best Practices for Vue Applications
TypeScript has revolutionized how we write JavaScript by adding static type checking. When combined with Vue.js, it provides an excellent developer experience with better IDE support, early error detection, and improved code maintainability.
Setting Up TypeScript in Vue
Basic Configuration
Your tsconfig.json should include these essential settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true
}
}
Type-Safe Component Development
Defining Props with TypeScript
Use interfaces for complex prop definitions:
// types/blog.ts
export interface BlogPost {
id: string
title: string
content: string
author: Author
publishedAt: Date
tags: string[]
metadata?: PostMetadata
}
export interface Author {
name: string
email: string
avatar?: string
}
export interface PostMetadata {
readingTime: number
wordCount: number
featured: boolean
}
<!-- components/BlogPost.vue -->
<template>
<article class="blog-post">
<header>
<h1>{{ post.title }}</h1>
<div class="author-info">
<img :src="post.author.avatar" :alt="post.author.name">
<span>{{ post.author.name }}</span>
</div>
</header>
<div v-html="post.content"></div>
</article>
</template>
<script setup lang="ts">
import type { BlogPost } from '~/types/blog'
interface Props {
post: BlogPost
showMetadata?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showMetadata: true
})
</script>
Composables with TypeScript
Create type-safe composables:
// composables/useBlog.ts
export const useBlog = () => {
const posts = ref<BlogPost[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const fetchPosts = async (): Promise<void> => {
loading.value = true
error.value = null
try {
const response = await $fetch<{ posts: BlogPost[] }>('/api/posts')
posts.value = response.posts
} catch (err) {
error.value = err instanceof Error ? err.message : 'An error occurred'
} finally {
loading.value = false
}
}
const getPostById = (id: string): BlogPost | undefined => {
return posts.value.find(post => post.id === id)
}
const getPostsByAuthor = (authorName: string): BlogPost[] => {
return posts.value.filter(post => post.author.name === authorName)
}
return {
posts: readonly(posts),
loading: readonly(loading),
error: readonly(error),
fetchPosts,
getPostById,
getPostsByAuthor
}
}
Advanced TypeScript Patterns
Generic Components
Create reusable components with generics:
<template>
<div class="data-table">
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="getItemKey(item)">
<td v-for="column in columns" :key="column.key">
<component
:is="column.component || 'span'"
:item="item"
:value="getValue(item, column.key)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
interface Column<T> {
key: keyof T
label: string
component?: Component
}
interface Props<T> {
items: T[]
columns: Column<T>[]
keyField: keyof T
}
const props = defineProps<Props<T>>()
const getItemKey = (item: T): string => {
return String(item[props.keyField])
}
const getValue = (item: T, key: keyof T): any => {
return item[key]
}
</script>
Type Guards
Implement type guards for runtime type checking:
// utils/typeGuards.ts
export function isString(value: unknown): value is string {
return typeof value === 'string'
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value)
}
export function isBlogPost(value: unknown): value is BlogPost {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'title' in value &&
'content' in value &&
'author' in value &&
isString((value as any).id) &&
isString((value as any).title)
)
}
// Usage in components
const validateAndSetPost = (data: unknown) => {
if (isBlogPost(data)) {
post.value = data // TypeScript knows this is BlogPost
} else {
throw new Error('Invalid blog post data')
}
}
Utility Types
Leverage TypeScript utility types:
// Create partial update types
type BlogPostUpdate = Partial<Pick<BlogPost, 'title' | 'content' | 'tags'>>
// Create required subsets
type BlogPostSummary = Pick<BlogPost, 'id' | 'title' | 'author' | 'publishedAt'>
// Create discriminated unions
type APIResponse<T> =
| { success: true; data: T }
| { success: false; error: string }
// Usage
const updatePost = async (id: string, update: BlogPostUpdate): Promise<APIResponse<BlogPost>> => {
try {
const updatedPost = await $fetch<BlogPost>(`/api/posts/${id}`, {
method: 'PATCH',
body: update
})
return { success: true, data: updatedPost }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Update failed'
}
}
}
Testing with TypeScript
Type-Safe Test Setup
// tests/utils/testHelpers.ts
export const createMockBlogPost = (overrides: Partial<BlogPost> = {}): BlogPost => {
return {
id: 'test-id',
title: 'Test Post',
content: 'Test content',
author: {
name: 'Test Author',
email: 'test@example.com'
},
publishedAt: new Date(),
tags: ['test'],
...overrides
}
}
export const createMockAuthor = (overrides: Partial<Author> = {}): Author => {
return {
name: 'Test Author',
email: 'test@example.com',
...overrides
}
}
// tests/components/BlogPost.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BlogPost from '~/components/BlogPost.vue'
import { createMockBlogPost } from '~/tests/utils/testHelpers'
describe('BlogPost Component', () => {
it('renders blog post correctly', () => {
const mockPost = createMockBlogPost({
title: 'Custom Test Title',
author: {
name: 'Custom Author',
email: 'custom@example.com'
}
})
const wrapper = mount(BlogPost, {
props: { post: mockPost }
})
expect(wrapper.find('h1').text()).toBe('Custom Test Title')
expect(wrapper.find('.author-info span').text()).toBe('Custom Author')
})
})
Common TypeScript Pitfalls
1. Avoiding any
// ❌ Bad - loses type safety
const processData = (data: any) => {
return data.someProperty
}
// ✅ Good - use generics or specific types
const processData = <T extends { someProperty: unknown }>(data: T): T['someProperty'] => {
return data.someProperty
}
2. Proper Error Handling
// ❌ Bad - catching unknown errors
try {
await fetchData()
} catch (error) {
console.log(error.message) // TypeScript error
}
// ✅ Good - type-safe error handling
try {
await fetchData()
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.log(message)
}
3. Working with Vue Refs
// ❌ Bad - implicit any
const element = ref()
// ✅ Good - explicit typing
const element = ref<HTMLElement | null>(null)
const posts = ref<BlogPost[]>([])
Conclusion
TypeScript significantly improves the development experience when building Vue applications. By following these best practices, you can:
- Catch errors at compile time
- Improve IDE support and autocomplete
- Make your code more maintainable
- Enhance team collaboration
Remember to:
- Use strict TypeScript settings
- Define clear interfaces for your data
- Leverage utility types and generics
- Write type-safe tests
- Avoid
anywhenever possible
With these patterns, you'll build more robust and maintainable Vue applications! 🎯