Generated by claude-opus-4-5-20251101-thinking-32k on 2025-12-10, much comprehensive than the other two or three AI models.
- Why TypeScript Matters for Vue Developers
- TypeScript Fundamentals Beyond "Typed JavaScript"
- The
anyProblem and How to Fix It - Understanding Type Assertions:
as any as ReturnType<typeof ...> - Vue 3 + TypeScript: The Complete Picture
- Typing Composables (The Modern Vue Pattern)
- Pinia Store Typing
- Vue Router with TypeScript
- TanStack Query (Vue Query) Typing
- API Layer Typing Patterns
- Component Props, Emits, and Slots
- Generic Components
- Testing with TypeScript in Vue
- Common Patterns and Best Practices
- Migration Strategy: JS to TS
// JavaScript: You discover bugs at runtime
function processUser(user) {
return user.profile.settings.theme // 💥 Runtime error if any is undefined
}
// TypeScript: You discover bugs while coding
function processUser(user: User) {
return user.profile.settings.theme // ✅ Editor tells you immediately if wrong
}| Feature | JavaScript | TypeScript |
|---|---|---|
| Autocomplete | Limited | Full |
| Refactoring | Dangerous | Safe |
| Documentation | External | In-code |
| Bug Detection | Runtime | Compile-time |
| Team Collaboration | Error-prone | Self-documenting |
// ❌ Over-typing (unnecessary)
const name: string = 'John'
const age: number = 25
const isActive: boolean = true
// ✅ Let TypeScript infer
const name = 'John' // TypeScript knows it's string
const age = 25 // TypeScript knows it's number
const isActive = true // TypeScript knows it's boolean
// When you DO need to annotate:
let name: string // Variable declared without initialization
const items: string[] = [] // Empty arrays need type hints// The TypeScript Type Hierarchy
//
// unknown (top type - safest)
// │
// ┌────────┴────────┐
// │ All Types │
// │ (string, etc) │
// └────────┬────────┘
// │
// never (bottom type - impossible)
//
// any (escape hatch - bypasses everything)// Union type: can be either
type Status = 'loading' | 'success' | 'error'
type ID = string | number
// Type narrowing: TypeScript tracks which type you're using
function processId(id: ID) {
if (typeof id === 'string') {
// TypeScript knows id is string here
return id.toUpperCase()
}
// TypeScript knows id is number here
return id.toFixed(2)
}
// Discriminated unions (powerful pattern)
type ApiResponse =
| { status: 'success'; data: User[] }
| { status: 'error'; error: string }
| { status: 'loading' }
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'success':
// TypeScript knows response.data exists here
console.log(response.data)
break
case 'error':
// TypeScript knows response.error exists here
console.log(response.error)
break
case 'loading':
// TypeScript knows neither data nor error exist
console.log('Loading...')
}
}// Interface: Best for object shapes, extendable
interface User {
id: number
name: string
}
interface Admin extends User {
permissions: string[]
}
// Declaration merging (interfaces only)
interface User {
email: string // Now User has id, name, AND email
}
// Type: Best for unions, computed types, primitives
type Status = 'active' | 'inactive'
type UserOrAdmin = User | Admin
type Nullable<T> = T | null
// Type can also do objects (but can't be extended the same way)
type Point = {
x: number
y: number
}
// Practical rule:
// - Use interface for component props
// - Use type for everything else// Generic = "type parameter"
// Think of it like a function parameter, but for types
// Without generics
function getFirst(arr: any[]): any {
return arr[0] // 😞 Lost type information
}
// With generics
function getFirst<T>(arr: T[]): T {
return arr[0] // 😊 Type is preserved
}
const numbers = [1, 2, 3]
const first = getFirst(numbers) // TypeScript knows: number
const strings = ['a', 'b', 'c']
const firstStr = getFirst(strings) // TypeScript knows: string
// Multiple type parameters
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 }
}
// Generic constraints
function getLength<T extends { length: number }>(item: T): number {
return item.length
}
getLength('hello') // ✅ string has length
getLength([1, 2, 3]) // ✅ array has length
getLength(123) // ❌ number doesn't have lengthinterface User {
id: number
name: string
email: string
password: string
createdAt: Date
}
// Pick: Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// { id: number; name: string }
// Omit: Exclude specific properties
type UserWithoutPassword = Omit<User, 'password'>
// { id: number; name: string; email: string; createdAt: Date }
// Partial: Make all properties optional
type UserUpdate = Partial<User>
// { id?: number; name?: string; ... }
// Required: Make all properties required
type CompleteUser = Required<Partial<User>>
// Back to all required
// Record: Create object type with specific key/value types
type UserById = Record<number, User>
// { [key: number]: User }
// ReturnType: Extract return type of a function
function createUser() {
return { id: 1, name: 'John' }
}
type CreatedUser = ReturnType<typeof createUser>
// { id: number; name: string }
// Parameters: Extract parameter types
function greet(name: string, age: number) { }
type GreetParams = Parameters<typeof greet>
// [string, number]
// Awaited: Unwrap Promise type
type UserPromise = Promise<User>
type ResolvedUser = Awaited<UserPromise>
// User// 'any' completely disables type checking
function process(data: any) {
data.foo.bar.baz() // No error, but might crash at runtime!
data.nonExistent() // No error, but will definitely crash!
}
// This defeats the entire purpose of TypeScript// From most permissive to most strict:
// 1. any - Disables all checks (avoid!)
let data: any
// 2. unknown - Safe "any" (must narrow before use)
let data: unknown
// data.foo // ❌ Error: must narrow first
if (typeof data === 'object' && data !== null && 'foo' in data) {
// ✅ Now you can access data.foo
}
// 3. Generic - Preserves actual type
function process<T>(data: T): T {
return data
}
// 4. Explicit type - Best when you know the shape
interface ApiData {
users: User[]
total: number
}
let data: ApiData// ❌ Problem: API response typed as any
const response = await fetch('/api/users')
const data = await response.json() // data is 'any'
// ✅ Solution 1: Type assertion
const data = await response.json() as User[]
// ✅ Solution 2: Generic function
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
return response.json()
}
const users = await fetchJson<User[]>('/api/users')
// ✅ Solution 3: Zod validation (runtime + types)
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
})
const data = UserSchema.parse(await response.json())
// data is now typed AND validated// Let's break down: as any as ReturnType<typeof useTodosQuery.useTodosQuery>
// Step 1: typeof useTodosQuery.useTodosQuery
// Gets the TYPE of the function (not calling it)
const fn = useTodosQuery.useTodosQuery
type FnType = typeof fn
// = (options?: Options) => { data: Ref<Todo[]>, isLoading: Ref<boolean>, ... }
// Step 2: ReturnType<...>
// Extracts what the function returns
type QueryReturn = ReturnType<typeof useTodosQuery.useTodosQuery>
// = { data: Ref<Todo[]>, isLoading: Ref<boolean>, ... }
// Step 3: as any as Type (Double assertion)
// This is a "type override" pattern for when TS doesn't trust your assertion
// Single assertion might fail:
const mock = {} as QueryReturn // ❌ Error: {} is not assignable to QueryReturn
// Double assertion bypasses this:
const mock = {} as any as QueryReturn // ✅ Works (but you're promising TS it's correct)// Common scenario: Mocking in tests
import { vi } from 'vitest'
import * as todosQuery from '@/composables/useTodosQuery'
// The mock doesn't have all the properties of the real return type
vi.spyOn(todosQuery, 'useTodosQuery').mockReturnValue({
data: ref([]),
isLoading: ref(false),
// We might be missing: error, refetch, isFetching, etc.
} as any as ReturnType<typeof todosQuery.useTodosQuery>)
// Why we need it:
// 1. Direct assertion fails because mock is incomplete
// 2. Using 'any' alone loses all type info for the test
// 3. This pattern: bypasses check BUT documents expected type// Alternative 1: Provide complete mock
const completeMock: ReturnType<typeof todosQuery.useTodosQuery> = {
data: ref([]),
isLoading: ref(false),
error: ref(null),
refetch: vi.fn(),
isFetching: ref(false),
// ... all required properties
}
// Alternative 2: Create a mock factory
function createQueryMock(
overrides: Partial<ReturnType<typeof todosQuery.useTodosQuery>> = {}
) {
return {
data: ref([]),
isLoading: ref(false),
error: ref(null),
refetch: vi.fn(),
isFetching: ref(false),
...overrides,
}
}
vi.spyOn(todosQuery, 'useTodosQuery').mockReturnValue(createQueryMock())
// Alternative 3: Satisfies pattern (TypeScript 4.9+)
const mock = {
data: ref([]),
isLoading: ref(false),
} satisfies Partial<ReturnType<typeof todosQuery.useTodosQuery>><script setup lang="ts">
// The "lang="ts"" enables TypeScript in this component
import { ref, computed, onMounted } from 'vue'
// Refs are typed automatically
const count = ref(0) // Ref<number>
const name = ref('Vue') // Ref<string>
const user = ref<User | null>(null) // Explicit generic for complex types
// Computed types are inferred
const doubled = computed(() => count.value * 2) // ComputedRef<number>
// Reactive needs explicit typing for complex objects
import { reactive } from 'vue'
interface State {
users: User[]
loading: boolean
error: string | null
}
const state = reactive<State>({
users: [],
loading: false,
error: null,
})
</script><script setup lang="ts">
import { ref, onMounted } from 'vue'
// DOM element refs
const inputRef = ref<HTMLInputElement | null>(null)
const divRef = ref<HTMLDivElement | null>(null)
// Component refs (need InstanceType)
import MyComponent from './MyComponent.vue'
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
onMounted(() => {
// Must check for null
inputRef.value?.focus()
// Access component methods
componentRef.value?.someMethod()
})
</script>
<template>
<input ref="inputRef" />
<MyComponent ref="componentRef" />
</template><script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'
const user = ref<User | null>(null)
const count = ref(0)
// Watch single ref - newVal and oldVal are inferred
watch(count, (newVal, oldVal) => {
// newVal: number, oldVal: number
console.log(newVal, oldVal)
})
// Watch with options
watch(user, (newUser) => {
// newUser: User | null
if (newUser) {
console.log(newUser.name) // TypeScript knows newUser is User here
}
}, { immediate: true, deep: true })
// Watch multiple sources
watch([count, user], ([newCount, newUser], [oldCount, oldUser]) => {
// Types are correctly inferred as tuple
})
// Watch getter
watch(
() => user.value?.name,
(newName) => {
// newName: string | undefined
}
)
</script>// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
// Return type is automatically inferred
return {
count, // Ref<number>
doubled, // ComputedRef<number>
increment, // () => void
decrement, // () => void
}
}
// TypeScript infers the return type automatically!
// But you can be explicit if needed:
export function useCounter(initialValue = 0): {
count: Ref<number>
doubled: ComputedRef<number>
increment: () => void
decrement: () => void
} {
// ...
}// composables/useFetch.ts
import { ref, unref, watchEffect } from 'vue'
import type { Ref } from 'vue'
// Define options interface
interface UseFetchOptions {
immediate?: boolean
refetch?: boolean
}
// MaybeRef pattern - accepts both ref and plain value
type MaybeRef<T> = T | Ref<T>
export function useFetch<T>(
url: MaybeRef<string>,
options: UseFetchOptions = {}
) {
const { immediate = true, refetch = true } = options
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function execute() {
isLoading.value = true
error.value = null
try {
const response = await fetch(unref(url))
if (!response.ok) throw new Error(response.statusText)
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading.value = false
}
}
if (refetch) {
watchEffect(() => {
unref(url) // Track the url
execute()
})
} else if (immediate) {
execute()
}
return {
data,
error,
isLoading,
execute,
}
}
// Usage with generic
const { data } = useFetch<User[]>('/api/users')
// data is Ref<User[] | null>// composables/usePagination.ts
import { ref, computed, type ComputedRef, type Ref } from 'vue'
interface PaginationState {
currentPage: Ref<number>
pageSize: Ref<number>
totalItems: Ref<number>
totalPages: ComputedRef<number>
hasNextPage: ComputedRef<boolean>
hasPrevPage: ComputedRef<boolean>
nextPage: () => void
prevPage: () => void
goToPage: (page: number) => void
setPageSize: (size: number) => void
setTotalItems: (total: number) => void
}
export function usePagination(
initialPage = 1,
initialPageSize = 10
): PaginationState {
const currentPage = ref(initialPage)
const pageSize = ref(initialPageSize)
const totalItems = ref(0)
const totalPages = computed(() =>
Math.ceil(totalItems.value / pageSize.value) || 1
)
const hasNextPage = computed(() =>
currentPage.value < totalPages.value
)
const hasPrevPage = computed(() =>
currentPage.value > 1
)
function nextPage() {
if (hasNextPage.value) currentPage.value++
}
function prevPage() {
if (hasPrevPage.value) currentPage.value--
}
function goToPage(page: number) {
const validPage = Math.max(1, Math.min(page, totalPages.value))
currentPage.value = validPage
}
function setPageSize(size: number) {
pageSize.value = size
currentPage.value = 1 // Reset to first page
}
function setTotalItems(total: number) {
totalItems.value = total
}
return {
currentPage,
pageSize,
totalItems,
totalPages,
hasNextPage,
hasPrevPage,
nextPage,
prevPage,
goToPage,
setPageSize,
setTotalItems,
}
}// stores/user.ts
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
}
interface UserState {
currentUser: User | null
isAuthenticated: boolean
preferences: {
theme: 'light' | 'dark'
language: string
}
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
currentUser: null,
isAuthenticated: false,
preferences: {
theme: 'light',
language: 'en',
},
}),
getters: {
// Getters receive state as first argument
userName: (state): string => state.currentUser?.name ?? 'Guest',
// Use `this` for other getters (must use function syntax)
userDisplayName(): string {
return `${this.userName} (${this.preferences.language})`
},
},
actions: {
// Actions can be async and access `this`
async login(email: string, password: string): Promise<boolean> {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
const user: User = await response.json()
this.currentUser = user
this.isAuthenticated = true
return true
} catch {
return false
}
},
logout() {
this.currentUser = null
this.isAuthenticated = false
},
setTheme(theme: 'light' | 'dark') {
this.preferences.theme = theme
},
},
})// stores/todos.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface Todo {
id: number
title: string
completed: boolean
createdAt: Date
}
export const useTodoStore = defineStore('todos', () => {
// State
const todos = ref<Todo[]>([])
const filter = ref<'all' | 'active' | 'completed'>('all')
const isLoading = ref(false)
// Getters (computed)
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed)
case 'completed':
return todos.value.filter(t => t.completed)
default:
return todos.value
}
})
const stats = computed(() => ({
total: todos.value.length,
completed: todos.value.filter(t => t.completed).length,
active: todos.value.filter(t => !t.completed).length,
}))
// Actions
async function fetchTodos() {
isLoading.value = true
try {
const response = await fetch('/api/todos')
todos.value = await response.json()
} finally {
isLoading.value = false
}
}
function addTodo(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
createdAt: new Date(),
}
todos.value.push(newTodo)
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
function removeTodo(id: number) {
const index = todos.value.findIndex(t => t.id === id)
if (index !== -1) {
todos.value.splice(index, 1)
}
}
return {
// State
todos,
filter,
isLoading,
// Getters
filteredTodos,
stats,
// Actions
fetchTodos,
addTodo,
toggleTodo,
removeTodo,
}
})
// Usage with full type inference
const store = useTodoStore()
store.todos // Todo[]
store.stats.completed // number
store.addTodo('New') // TypeScript checks argument type// For use in components that receive store as prop
import type { useTodoStore } from '@/stores/todos'
// Get the store instance type
type TodoStore = ReturnType<typeof useTodoStore>
// Get just the state type
type TodoState = TodoStore['$state']
// Use in props
interface Props {
store: TodoStore
}// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// Define your route names as const for type safety
export const routeNames = {
home: 'home',
userProfile: 'user-profile',
userSettings: 'user-settings',
notFound: 'not-found',
} as const
const routes: RouteRecordRaw[] = [
{
path: '/',
name: routeNames.home,
component: () => import('@/views/HomeView.vue'),
},
{
path: '/user/:id',
name: routeNames.userProfile,
component: () => import('@/views/UserProfile.vue'),
props: true, // Pass route params as props
},
{
path: '/user/:id/settings',
name: routeNames.userSettings,
component: () => import('@/views/UserSettings.vue'),
meta: {
requiresAuth: true,
roles: ['admin', 'user'],
},
},
{
path: '/:pathMatch(.*)*',
name: routeNames.notFound,
component: () => import('@/views/NotFound.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router// router/types.ts
import 'vue-router'
// Extend the RouteMeta interface
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
layout?: 'default' | 'blank' | 'dashboard'
}
}
// Now in components:
import { useRoute } from 'vue-router'
const route = useRoute()
route.meta.requiresAuth // boolean | undefined (typed!)
route.meta.roles // string[] | undefined (typed!)// In a component
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// Params are typed as string | string[]
const userId = route.params.id // string | string[]
// For specific routes, you might want to assert
const userId = route.params.id as string
// Query params
const page = route.query.page // string | string[] | null
const pageNum = Number(route.query.page) || 1
// Programmatic navigation with type checking
router.push({ name: 'user-profile', params: { id: '123' } })
// Typed navigation helper
function goToUser(id: number) {
router.push({
name: 'user-profile',
params: { id: String(id) }
})
}
</script>// router/guards.ts
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
export function authGuard(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) {
const requiresAuth = to.meta.requiresAuth
if (requiresAuth && !isAuthenticated()) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
}
}
// Or using the newer syntax with return
export function authGuard(to: RouteLocationNormalized) {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { name: 'login', query: { redirect: to.fullPath } }
}
// Returning nothing (undefined) means continue
}// composables/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import type { User } from '@/types'
// API functions (typed separately)
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users')
if (!response.ok) throw new Error('Failed to fetch users')
return response.json()
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('Failed to fetch user')
return response.json()
}
// Query composable
export function useUsersQuery() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
// All options are fully typed
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useUserQuery(id: Ref<number> | number) {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(unref(id)),
// Enable only when id is valid
enabled: computed(() => unref(id) > 0),
})
}// composables/useUserMutations.ts
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import type { User } from '@/types'
interface CreateUserData {
name: string
email: string
}
interface UpdateUserData {
id: number
name?: string
email?: string
}
async function createUser(data: CreateUserData): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Failed to create user')
return response.json()
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// newUser is typed as User
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => {
// error is typed as Error
console.error('Failed to create user:', error.message)
},
})
}
// Usage in component
const createUserMutation = useCreateUser()
async function handleSubmit() {
try {
const newUser = await createUserMutation.mutateAsync({
name: 'John',
email: '[email protected]',
})
// newUser is typed as User
console.log('Created:', newUser.id)
} catch (error) {
// Handle error
}
}// composables/useTodos.ts
import { useQuery } from '@tanstack/vue-query'
import { z } from 'zod'
// Define schema
const TodoSchema = z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
userId: z.number(),
})
const TodosResponseSchema = z.array(TodoSchema)
// Infer type from schema
type Todo = z.infer<typeof TodoSchema>
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = await response.json()
// Validate AND parse (throws if invalid)
return TodosResponseSchema.parse(data)
}
export function useTodosQuery() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// No need to specify generic - inferred from queryFn return type
})
}// api/client.ts
interface ApiResponse<T> {
data: T
message: string
success: boolean
}
interface ApiError {
message: string
code: string
details?: Record<string, string[]>
}
class ApiClient {
private baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error: ApiError = await response.json()
throw new Error(error.message)
}
const result: ApiResponse<T> = await response.json()
return result.data
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint)
}
async post<T, D = unknown>(endpoint: string, data: D): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
})
}
async put<T, D = unknown>(endpoint: string, data: D): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' })
}
}
export const api = new ApiClient('/api')
// Usage
interface User {
id: number
name: string
}
const user = await api.get<User>('/users/1') // User
const users = await api.get<User[]>('/users') // User[]
const created = await api.post<User, { name: string }>('/users', { name: 'John' })// api/endpoints/users.ts
import { api } from '../client'
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user' | 'guest'
}
export interface CreateUserDto {
name: string
email: string
password: string
role?: User['role']
}
export interface UpdateUserDto {
name?: string
email?: string
role?: User['role']
}
export interface UserFilters {
role?: User['role']
search?: string
page?: number
limit?: number
}
export const usersApi = {
getAll: (filters?: UserFilters) =>
api.get<User[]>(`/users${filters ? `?${new URLSearchParams(filters as Record<string, string>)}` : ''}`),
getById: (id: number) =>
api.get<User>(`/users/${id}`),
create: (data: CreateUserDto) =>
api.post<User, CreateUserDto>('/users', data),
update: (id: number, data: UpdateUserDto) =>
api.put<User, UpdateUserDto>(`/users/${id}`, data),
delete: (id: number) =>
api.delete<void>(`/users/${id}`),
}
// Usage
const users = await usersApi.getAll({ role: 'admin' })
const user = await usersApi.create({ name: 'John', email: '[email protected]', password: '123' })<script setup lang="ts">
// Method 1: Runtime declaration with type inference
const props = defineProps({
title: String,
count: { type: Number, required: true },
items: { type: Array as PropType<string[]>, default: () => [] },
})
// Method 2: Type-based declaration (PREFERRED)
interface Props {
title?: string
count: number
items?: string[]
user: User
status: 'active' | 'inactive'
onUpdate?: (value: string) => void
}
const props = defineProps<Props>()
// Method 3: With defaults
interface Props {
title?: string
count?: number
items?: string[]
}
const props = withDefaults(defineProps<Props>(), {
title: 'Default Title',
count: 0,
items: () => [], // Functions for non-primitive defaults
})
</script><script setup lang="ts">
// Method 1: Array syntax (less type-safe)
const emit = defineEmits(['update', 'delete', 'submit'])
// Method 2: Object syntax with validation
const emit = defineEmits({
update: (id: number, value: string) => true, // validation function
delete: (id: number) => id > 0,
submit: () => true,
})
// Method 3: Type-based declaration (PREFERRED)
interface Emits {
(e: 'update', id: number, value: string): void
(e: 'delete', id: number): void
(e: 'submit'): void
}
const emit = defineEmits<Emits>()
// Method 4: Vue 3.3+ shorthand
const emit = defineEmits<{
update: [id: number, value: string] // Labeled tuple
delete: [id: number]
submit: []
}>()
// Usage
emit('update', 1, 'new value') // ✅
emit('update', '1', 'value') // ❌ TypeScript error: string not assignable to number
emit('unknown') // ❌ TypeScript error: invalid event
</script><!-- ParentComponent.vue -->
<script setup lang="ts">
interface User {
id: number
name: string
}
// Define slot types
defineSlots<{
// Default slot with no props
default: () => void
// Named slot with props
header: (props: { title: string }) => void
// Scoped slot
item: (props: { user: User; index: number }) => void
// Optional slot
footer?: () => void
}>()
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]
</script>
<template>
<div>
<header>
<slot name="header" :title="'User List'" />
</header>
<main>
<slot />
<div v-for="(user, index) in users" :key="user.id">
<slot name="item" :user="user" :index="index" />
</div>
</main>
<footer>
<slot name="footer" />
</footer>
</div>
</template>
<!-- ChildUsage.vue -->
<template>
<ParentComponent>
<template #header="{ title }">
<!-- title is typed as string -->
<h1>{{ title }}</h1>
</template>
<template #item="{ user, index }">
<!-- user is typed as User, index as number -->
<div>{{ index + 1 }}. {{ user.name }}</div>
</template>
<template #default>
<p>Main content</p>
</template>
</ParentComponent>
</template><!-- ChildComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
function increment() {
count.value++
}
function focus() {
inputRef.value?.focus()
}
// Explicitly expose public interface
defineExpose({
count,
increment,
focus,
})
</script>
<!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
// Get the exposed type
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
function handleClick() {
// TypeScript knows these are available
childRef.value?.increment()
childRef.value?.focus()
console.log(childRef.value?.count)
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="handleClick">Interact with child</button>
</template><!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
// T is now a type parameter available in the component
interface Props {
items: T[]
selected?: T
}
interface Emits {
(e: 'select', item: T): void
(e: 'delete', id: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
function handleSelect(item: T) {
emit('select', item)
}
</script>
<template>
<ul>
<li
v-for="item in items"
:key="item.id"
:class="{ selected: selected?.id === item.id }"
@click="handleSelect(item)"
>
<slot :item="item">
{{ item }}
</slot>
</li>
</ul>
</template>
<!-- Usage -->
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
const users: User[] = [
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' },
]
const selectedUser = ref<User | undefined>()
function onSelect(user: User) {
// user is typed as User, not just { id: number }
selectedUser.value = user
console.log(user.email) // ✅ TypeScript knows about email
}
</script>
<template>
<GenericList
:items="users"
:selected="selectedUser"
@select="onSelect"
>
<template #default="{ item }">
<!-- item is typed as User -->
{{ item.name }} ({{ item.email }})
</template>
</GenericList>
</template><script setup lang="ts" generic="T, K extends keyof T">
interface Props {
items: T[]
labelKey: K
valueKey: K
}
const props = defineProps<Props>()
// Access the value dynamically with type safety
function getLabel(item: T): T[K] {
return item[props.labelKey]
}
</script>
<!-- Usage -->
<template>
<MySelect
:items="users"
label-key="name"
value-key="id"
/>
</template>// tests/components/TodoItem.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoItem from '@/components/TodoItem.vue'
import type { Todo } from '@/types'
describe('TodoItem', () => {
const createTodo = (overrides: Partial<Todo> = {}): Todo => ({
id: 1,
title: 'Test Todo',
completed: false,
...overrides,
})
it('renders todo title', () => {
const todo = createTodo({ title: 'My Todo' })
const wrapper = mount(TodoItem, {
props: { todo },
})
expect(wrapper.text()).toContain('My Todo')
})
it('emits toggle event with todo id', async () => {
const todo = createTodo({ id: 42 })
const wrapper = mount(TodoItem, {
props: { todo },
})
await wrapper.find('input[type="checkbox"]').trigger('change')
// Type-safe emit assertion
const emitted = wrapper.emitted('toggle')
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([42])
})
})// tests/composables/useTodos.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import * as todosQuery from '@/composables/useTodosQuery'
// Create a typed mock factory
function createMockQuery<T>(data: T) {
return {
data: ref(data),
isLoading: ref(false),
isError: ref(false),
error: ref(null),
refetch: vi.fn(),
isFetching: ref(false),
isSuccess: ref(true),
}
}
describe('useTodos', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns todos data', () => {
const mockTodos = [
{ id: 1, title: 'Todo 1', completed: false },
{ id: 2, title: 'Todo 2', completed: true },
]
vi.spyOn(todosQuery, 'useTodosQuery').mockReturnValue(
createMockQuery(mockTodos)
)
const { data } = todosQuery.useTodosQuery()
expect(data.value).toHaveLength(2)
expect(data.value[0].title).toBe('Todo 1')
})
it('handles loading state', () => {
const mockReturn = createMockQuery([])
mockReturn.isLoading.value = true
vi.spyOn(todosQuery, 'useTodosQuery').mockReturnValue(mockReturn)
const { isLoading } = todosQuery.useTodosQuery()
expect(isLoading.value).toBe(true)
})
})// tests/stores/todos.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTodoStore } from '@/stores/todos'
describe('Todo Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('adds a todo', () => {
const store = useTodoStore()
store.addTodo('New Todo')
expect(store.todos).toHaveLength(1)
expect(store.todos[0].title).toBe('New Todo')
expect(store.todos[0].completed).toBe(false)
})
it('toggles todo completion', () => {
const store = useTodoStore()
store.addTodo('Test Todo')
const todoId = store.todos[0].id
store.toggleTodo(todoId)
expect(store.todos[0].completed).toBe(true)
})
it('computes filtered todos correctly', () => {
const store = useTodoStore()
store.addTodo('Todo 1')
store.addTodo('Todo 2')
store.toggleTodo(store.todos[0].id)
store.filter = 'active'
expect(store.filteredTodos).toHaveLength(1)
expect(store.filteredTodos[0].title).toBe('Todo 2')
store.filter = 'completed'
expect(store.filteredTodos).toHaveLength(1)
expect(store.filteredTodos[0].title).toBe('Todo 1')
})
})// ✅ Good: Asserting API responses you trust
const user = (await api.get('/users/1')) as User
// ✅ Good: Event target assertions
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
console.log(target.value)
}
// ✅ Good: Narrowing from unknown
function processData(data: unknown) {
if (isUser(data)) {
// data is User inside this block
}
}
// ❌ Bad: Lying to TypeScript
const data = {} as User // This is NOT a User!
console.log(data.name) // Will crash at runtime
// Type guard function
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data
)
}// Perfect for API state management
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function useAsyncData<T>(fetcher: () => Promise<T>) {
const state = ref<AsyncState<T>>({ status: 'idle' })
async function execute() {
state.value = { status: 'loading' }
try {
const data = await fetcher()
state.value = { status: 'success', data }
} catch (e) {
state.value = {
status: 'error',
error: e instanceof Error ? e : new Error(String(e))
}
}
}
return { state, execute }
}
// In component
<template>
<div v-if="state.status === 'loading'">Loading...</div>
<div v-else-if="state.status === 'error'">{{ state.error.message }}</div>
<div v-else-if="state.status === 'success'">
<!-- TypeScript knows state.data exists here -->
{{ state.data }}
</div>
</template>// Let TypeScript infer complex return types
function createStore<T extends Record<string, unknown>>(initial: T) {
const state = reactive(initial)
function setState<K extends keyof T>(key: K, value: T[K]) {
state[key] = value
}
return { state, setState }
// Return type is inferred - no need to write it!
}
// Extract types from existing code
const store = createStore({ count: 0, name: '' })
type StoreType = typeof store
type StateType = typeof store.state// Without const assertion
const config = {
endpoint: '/api',
timeout: 5000,
}
// Type: { endpoint: string; timeout: number }
// With const assertion
const config = {
endpoint: '/api',
timeout: 5000,
} as const
// Type: { readonly endpoint: "/api"; readonly timeout: 5000 }
// Useful for route names, action types, etc.
const ROUTES = {
home: '/',
users: '/users',
userDetail: '/users/:id',
} as const
type RouteKey = keyof typeof ROUTES
// 'home' | 'users' | 'userDetail'
type RoutePath = typeof ROUTES[RouteKey]
// '/' | '/users' | '/users/:id'// Problem: as Type can be too loose
const config = {
port: 3000,
host: 'localhost',
} as Config
// TypeScript doesn't check if properties are correct
// Solution: satisfies
const config = {
port: 3000,
host: 'localhost',
} satisfies Config
// TypeScript validates config matches Config
// BUT keeps the more specific literal types
// Practical example
type Colors = Record<string, string>
// With type annotation - loses specificity
const colors: Colors = {
primary: '#ff0000',
secondary: '#00ff00',
}
colors.primary // string (not the specific hex)
// With satisfies - keeps specificity
const colors = {
primary: '#ff0000',
secondary: '#00ff00',
} satisfies Colors
colors.primary // '#ff0000' (literal type preserved!)# Step 1: Add TypeScript support
npm install -D typescript vue-tsc @types/node
# Step 2: Create tsconfig.json
npx tsc --init// tsconfig.json for Vue
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}<!-- Before: JavaScript -->
<script setup>
import { ref } from 'vue'
const props = defineProps({
title: String,
count: { type: Number, required: true }
})
const items = ref([])
const user = ref(null)
function addItem(item) {
items.value.push(item)
}
</script>
<!-- After: TypeScript -->
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
title?: string
count: number
}
interface Item {
id: number
name: string
}
interface User {
id: number
name: string
}
const props = defineProps<Props>()
const items = ref<Item[]>([])
const user = ref<User | null>(null)
function addItem(item: Item) {
items.value.push(item)
}
</script>// Start with lenient settings
{
"compilerOptions": {
"strict": false,
"noImplicitAny": false, // Allow implicit any initially
"strictNullChecks": false // Don't enforce null checks initially
}
}
// Gradually enable strict checks
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true, // First: require explicit types
"strictNullChecks": false
}
}
// Then enable null checks
{
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true // Now: handle null/undefined
}
}
// Finally: full strict mode
{
"compilerOptions": {
"strict": true // All strict checks enabled
}
}// === PROPS ===
const props = defineProps<{
title: string
count?: number
}>()
// === EMITS ===
const emit = defineEmits<{
update: [value: string]
submit: []
}>()
// === REFS ===
const count = ref(0) // Ref<number>
const user = ref<User | null>(null) // Ref<User | null>
const input = ref<HTMLInputElement>() // Ref<HTMLInputElement | undefined>
// === COMPUTED ===
const doubled = computed(() => count.value * 2) // ComputedRef<number>
// === COMPOSABLE ===
function useFeature() {
const data = ref<Data[]>([])
const load = async () => { /* ... */ }
return { data, load }
}
// === STORE (PINIA) ===
export const useStore = defineStore('name', () => {
const items = ref<Item[]>([])
const total = computed(() => items.value.length)
function add(item: Item) { items.value.push(item) }
return { items, total, add }
})
// === COMMON PATTERNS ===
type Maybe<T> = T | null | undefined
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
// === TYPE EXTRACTION ===
type PropType = typeof props
type EmitType = typeof emit
type ReturnOfFn = ReturnType<typeof someFunction>
type StoreType = ReturnType<typeof useStore>The key mindset shifts for JS → TS in Vue:
- Think in types first: Design your interfaces before implementing
- Let inference work: Don't over-annotate; TypeScript is smart
- Use generic composables: They provide type safety across your app
- Embrace discriminated unions: Perfect for handling async states
- Avoid
any: Useunknown+ type guards instead - Type your API layer: This is where most runtime errors originate
The as any as ReturnType<typeof fn> pattern is a escape hatch for testing—in production code, strive for proper typing that doesn't need such workarounds.