Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save suntong/60ca82a5d7ffd9aabee59d445cfcfd87 to your computer and use it in GitHub Desktop.

Select an option

Save suntong/60ca82a5d7ffd9aabee59d445cfcfd87 to your computer and use it in GitHub Desktop.

TypeScript for Vue.js Developers: A Comprehensive & Practical Guide

Generated by claude-opus-4-5-20251101-thinking-32k on 2025-12-10, much comprehensive than the other two or three AI models.

Table of Contents

  1. Why TypeScript Matters for Vue Developers
  2. TypeScript Fundamentals Beyond "Typed JavaScript"
  3. The any Problem and How to Fix It
  4. Understanding Type Assertions: as any as ReturnType<typeof ...>
  5. Vue 3 + TypeScript: The Complete Picture
  6. Typing Composables (The Modern Vue Pattern)
  7. Pinia Store Typing
  8. Vue Router with TypeScript
  9. TanStack Query (Vue Query) Typing
  10. API Layer Typing Patterns
  11. Component Props, Emits, and Slots
  12. Generic Components
  13. Testing with TypeScript in Vue
  14. Common Patterns and Best Practices
  15. Migration Strategy: JS to TS

1. Why TypeScript Matters for Vue Developers

The Real Benefits (Beyond Type Safety)

// 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
}

What TypeScript Actually Gives You

Feature JavaScript TypeScript
Autocomplete Limited Full
Refactoring Dangerous Safe
Documentation External In-code
Bug Detection Runtime Compile-time
Team Collaboration Error-prone Self-documenting

2. TypeScript Fundamentals Beyond "Typed JavaScript"

2.1 Type Inference (Let TS Work for You)

// ❌ 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

2.2 The Type System Hierarchy

// The TypeScript Type Hierarchy
//
//         unknown (top type - safest)
//             │
//    ┌────────┴────────┐
//    │    All Types    │
//    │  (string, etc)  │
//    └────────┬────────┘
//             │
//           never (bottom type - impossible)
//
//           any (escape hatch - bypasses everything)

2.3 Union Types and Narrowing

// 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...')
  }
}

2.4 Interface vs Type

// 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

2.5 Generics: The Power Feature

// 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 length

2.6 Utility Types

interface 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

3. The any Problem and How to Fix It

Why Linters Complain About any

// '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

The any Alternatives Spectrum

// 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

Common Scenarios and Fixes

// ❌ 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

4. Understanding Type Assertions: as any as ReturnType<typeof ...>

What's Actually Happening

// 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)

When and Why You Need This Pattern

// 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

Better Alternatives

// 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>>

5. Vue 3 + TypeScript: The Complete Picture

5.1 Script Setup Basics

<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>

5.2 Template Refs

<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>

5.3 Typing Watchers

<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>

6. Typing Composables (The Modern Vue Pattern)

6.1 Basic Composable

// 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
} {
  // ...
}

6.2 Composable with Options

// 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>

6.3 Composable with Complex Return Type

// 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,
  }
}

7. Pinia Store Typing

7.1 Options Store

// 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
    },
  },
})

7.2 Setup Store (Composition API Style)

// 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

7.3 Extracting Store Types

// 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
}

8. Vue Router with TypeScript

8.1 Route Definitions

// 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

8.2 Typed Route Meta

// 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!)

8.3 Typed useRoute and useRouter

// 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>

8.4 Navigation Guards with Types

// 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
}

9. TanStack Query (Vue Query) Typing

9.1 Basic Query

// 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),
  })
}

9.2 Mutations

// 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
  }
}

9.3 Query with Zod Validation

// 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
  })
}

10. API Layer Typing Patterns

10.1 Type-Safe API Client

// 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' })

10.2 Typed API Endpoints

// 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' })

11. Component Props, Emits, and Slots

11.1 Props with TypeScript

<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>

11.2 Emits with TypeScript

<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>

11.3 Slots with TypeScript

<!-- 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>

11.4 Expose with TypeScript

<!-- 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>

12. Generic Components

12.1 Generic Component with generic Attribute

<!-- 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>

12.2 Multiple Generic Parameters

<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>

13. Testing with TypeScript in Vue

13.1 Component Testing

// 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])
  })
})

13.2 Mocking Composables

// 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)
  })
})

13.3 Testing Pinia Stores

// 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')
  })
})

14. Common Patterns and Best Practices

14.1 Type Assertions (When to Use)

// ✅ 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
  )
}

14.2 Discriminated Unions for State

// 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>

14.3 Type Inference Tricks

// 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

14.4 Const Assertions

// 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'

14.5 Satisfies Operator (TypeScript 4.9+)

// 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!)

15. Migration Strategy: JS to TS

15.1 Gradual Migration Steps

# 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" }]
}

15.2 File-by-File Migration

<!-- 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>

15.3 Progressive Strictness

// 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
  }
}

Quick Reference Card

// === 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>

Summary

The key mindset shifts for JS → TS in Vue:

  1. Think in types first: Design your interfaces before implementing
  2. Let inference work: Don't over-annotate; TypeScript is smart
  3. Use generic composables: They provide type safety across your app
  4. Embrace discriminated unions: Perfect for handling async states
  5. Avoid any: Use unknown + type guards instead
  6. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment