Vue 3 với TypeScript: Xây dựng Component và Composable Type-safe


Tuan Duc Tran

Vue 3 và TypeScript bổ trợ cho nhau ở đúng nơi một ứng dụng frontend thường bắt đầu phức tạp: dữ liệu đi qua nhiều component, event có payload, trạng thái reactive thay đổi theo thời gian và logic cần được tách thành composable để tái sử dụng. TypeScript không thay thế runtime validation, nhưng nó giúp contract giữa template, component và composable được kiểm tra ngay trong quá trình phát triển.

Bài viết này chọn Composition API với <script setup lang="ts"> làm nền tảng. Mục tiêu không phải là thêm type annotation vào mọi dòng code, mà là thiết kế component sao cho cách dùng đúng được suy luận tự nhiên, còn lỗi truyền dữ liệu hoặc phát ra event sai bị phát hiện sớm.

1. Chọn một mô hình type rõ ràng cho component

Một component nên có ba loại contract dễ nhìn thấy: dữ liệu nhận vào qua props, sự kiện phát ra qua emits và trạng thái nội bộ. Nếu các contract này bị trộn lẫn với implementation, component sẽ khó tái sử dụng và khó kiểm thử.

Vue hỗ trợ hai cách khai báo props: runtime declaration và type-based declaration. Trong codebase TypeScript, type-based declaration thường dễ đọc hơn khi props có nhiều trường hoặc dùng type được chia sẻ. Hai cách không nên dùng đồng thời cho cùng một component.

UserCard.vue
<script setup lang="ts">
interface User {
  id: string
  name: string
  role: 'admin' | 'member'
  avatarUrl?: string
}
 
interface Props {
  user: User
  compact?: boolean
}
 
const props = withDefaults(defineProps<Props>(), {
  compact: false,
})
 
const emit = defineEmits<{
  select: [userId: string]
}>()
 
function handleSelect(): void {
  emit('select', props.user.id)
}
</script>
 
<template>
  <article :class="{ 'user-card--compact': compact }">
    <img
      v-if="user.avatarUrl"
      :src="user.avatarUrl"
      :alt="`Ảnh đại diện của ${user.name}`"
    />
    <h2>{{ user.name }}</h2>
    <p>{{ user.role }}</p>
    <button type="button" @click="handleSelect">
      Chọn người dùng
    </button>
  </article>
</template>

Trong template, props có thể được tự động unwrap thành usercompact. withDefaults giúp các prop có default trở thành bắt buộc trong phần implementation, đồng thời giữ interface public dễ hiểu. Với giá trị mutable như array hoặc object, cần tạo giá trị mặc định riêng cho từng instance thay vì dùng chung một object.

Khi props được import từ module khác, hãy dùng type-only import để tránh tạo ra import runtime không cần thiết.

import type { User } from '~/types/user'
 
interface Props {
  user: User
}
 
const props = defineProps<Props>()

2. Typed emits giúp event trở thành API thật

Một event không chỉ là tên chuỗi. Nó có tên, thứ tự tham số và kiểu của từng payload. Khai báo emits bằng named tuple làm cho contract này rõ ràng tại nơi phát và nơi bắt sự kiện.

const emit = defineEmits<{
  save: [draft: UserDraft]
  cancel: []
  validationError: [field: keyof UserDraft, message: string]
}>()
 
interface UserDraft {
  name: string
  role: 'admin' | 'member'
}
 
function saveDraft(draft: UserDraft): void {
  emit('save', draft)
}

Nếu component có quá nhiều event, đó có thể là dấu hiệu component đang làm quá nhiều việc. Hãy cân nhắc tách form, validation và persistence thành các boundary riêng thay vì tạo một event map khổng lồ.

Ở component cha, callback cũng nên có signature tương ứng. Không cần ghi type thủ công cho mọi handler nếu Vue Language Tools có thể suy luận, nhưng public function truyền qua nhiều module nên có kiểu trả về rõ ràng.

3. ref, computedreactive: để inference làm phần việc của nó

Vue thường suy luận tốt type của refcomputed từ giá trị khởi tạo. Việc chỉ định generic nên dành cho trường hợp state có nhiều trạng thái hoặc chưa có giá trị ban đầu.

import { computed, ref } from 'vue'
 
const count = ref(0)
const label = computed((): string => `Đã chọn ${count.value} mục`)
 
const selectedId = ref<string | null>(null)
const pendingRequest = ref<Promise<void> | null>(null)

Khi một state có nhiều trạng thái, union type thường diễn đạt đúng hơn một nhóm boolean rời rạc. Ví dụ, loading, errordata có thể rơi vào trạng thái mâu thuẫn nếu được quản lý bằng ba biến độc lập.

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string }
 
const state = ref<RequestState<User[]>>({ status: 'idle' })
 
function renderMessage(current: RequestState<User[]>): string {
  switch (current.status) {
    case 'idle':
      return 'Chưa tải dữ liệu'
    case 'loading':
      return 'Đang tải dữ liệu'
    case 'success':
      return `${current.data.length} người dùng`
    case 'error':
      return current.message
  }
}

Discriminated union làm cho control-flow narrowing hoạt động trong từng nhánh. Khi thêm một trạng thái mới, compiler có thể chỉ ra các nơi chưa được xử lý nếu hàm được viết theo hướng exhaustive.

reactive hữu ích cho object nhỏ có cấu trúc ổn định, nhưng không nên ép generic vào reactive một cách máy móc. Vue xử lý nested ref unwrapping, vì vậy type trả về có thể không giống hệt generic đã truyền vào. Với state phức tạp, thường dễ kiểm soát hơn khi dùng ref<State>() và thay thế object theo từng lần cập nhật.

4. Viết composable có input và output được mô hình hóa

Composable tốt không chỉ gom các dòng code reactive. Nó định nghĩa một abstraction có input rõ, output readonly khi cần và cleanup đúng lifecycle. Hãy bắt đầu bằng contract nhỏ nhất mà caller thật sự cần.

import { readonly, ref, type Ref } from 'vue'
 
interface UseSearchOptions {
  delayMs?: number
}
 
interface SearchState<T> {
  query: Ref<string>
  results: Readonly<Ref<T[]>>
  loading: Readonly<Ref<boolean>>
  search: (query: string) => Promise<void>
}
 
export function useSearch<T>(
  loader: (query: string) => Promise<T[]>,
  options: UseSearchOptions = {},
): SearchState<T> {
  const query = ref('')
  const results = ref<T[]>([])
  const loading = ref(false)
  const delayMs = options.delayMs ?? 250
 
  async function search(nextQuery: string): Promise<void> {
    query.value = nextQuery
    if (nextQuery.trim() === '') {
      results.value = []
      return
    }
 
    loading.value = true
    await new Promise((resolve) => setTimeout(resolve, delayMs))
 
    try {
      results.value = await loader(nextQuery)
    } finally {
      loading.value = false
    }
  }
 
  return {
    query,
    results: readonly(results),
    loading: readonly(loading),
    search,
  }
}

Ở đây, resultsloading được trả về readonly để caller không thể thay đổi state nội bộ một cách tùy ý. loader là dependency được truyền vào, nên composable không bị khóa vào một API cụ thể và có thể test bằng một function giả.

Composable bất đồng bộ thực tế cần xử lý thêm race condition. Nếu người dùng gõ liên tục, request cũ có thể hoàn thành sau request mới và ghi đè kết quả. Có thể giải quyết bằng AbortController, request id hoặc một thư viện data fetching; điều quan trọng là chọn một cơ chế duy nhất và mô hình hóa trạng thái đó rõ ràng.

import { onUnmounted, shallowRef } from 'vue'
 
interface User {
  id: string
  name: string
}
 
interface SearchClient {
  search(query: string, signal: AbortSignal): Promise<User[]>
}
 
export function useUserSearch(client: SearchClient): {
  searchUsers: (query: string) => Promise<User[]>
} {
  const controller = shallowRef<AbortController | null>(null)
 
  async function searchUsers(query: string): Promise<User[]> {
    controller.value?.abort()
    const nextController = new AbortController()
    controller.value = nextController
 
    return client.search(query, nextController.signal)
  }
 
  onUnmounted(() => {
    controller.value?.abort()
  })
 
  return { searchUsers }
}

Trong ví dụ này, cần import onUnmountedshallowRef từ Vue. shallowRef phù hợp với object điều khiển như AbortController, nơi deep reactivity không mang lại giá trị.

5. Type-safe provideinject

provideinject tạo dependency boundary giữa các component không trực tiếp truyền props cho nhau. Dùng string key làm mất thông tin type và dễ xung đột. InjectionKey<T> cho phép provider và consumer dùng chung một contract.

keys.ts
import type { InjectionKey, Ref } from 'vue'
 
export interface ThemeController {
  mode: Readonly<Ref<'light' | 'dark'>>
  toggle: () => void
}
 
export const themeKey: InjectionKey<ThemeController> = Symbol('theme')
ThemeProvider.vue
import { provide, ref } from 'vue'
import { themeKey, type ThemeController } from '~/keys'
 
const mode = ref<'light' | 'dark'>('light')
 
const controller: ThemeController = {
  mode,
  toggle: () => {
    mode.value = mode.value === 'light' ? 'dark' : 'light'
  },
}
 
provide(themeKey, controller)
ThemeConsumer.vue
import { inject } from 'vue'
import { themeKey } from '~/keys'
 
const theme = inject(themeKey)
 
if (!theme) {
  throw new Error('ThemeConsumer phải được dùng bên trong ThemeProvider')
}
 
theme.toggle()

inject vẫn có thể trả về undefined vì compiler không thể biết provider có tồn tại lúc runtime hay không. Đừng dùng assertion để che giấu điều này. Hoặc xử lý undefined, hoặc cung cấp default value, hoặc viết một helper injectStrict có lỗi rõ ràng khi component được dùng sai cây.

6. Template refs và event handler

Template ref là một điểm giao nhau giữa type system và DOM runtime. Khi ref trỏ vào một element cụ thể, hãy để Vue hoặc editor suy luận nếu có thể; nếu cần truy cập API DOM, dùng type phù hợp thay vì HTMLElement chung chung.

<script setup lang="ts">
import { nextTick, useTemplateRef } from 'vue'
 
const input = useTemplateRef<HTMLInputElement>('search-input')
 
async function focusInput(): Promise<void> {
  await nextTick()
  input.value?.focus()
}
</script>
 
<template>
  <input ref="search-input" type="search" />
  <button type="button" @click="focusInput">Tập trung ô tìm kiếm</button>
</template>

Với event native, tham số nên được chú thích khi TypeScript không thể suy luận từ template.

function handleChange(event: Event): void {
  const target = event.currentTarget
  if (!(target instanceof HTMLInputElement)) {
    return
  }
 
  console.log(target.value)
}

Ưu tiên currentTarget khi handler được gắn trực tiếp lên element. target có thể là một element con bên trong node nhận event, nên việc ép thẳng event.target as HTMLInputElement thường yếu hơn một guard kiểm tra runtime.

7. defineModel và ranh giới của two-way binding

defineModel giúp xây component input có v-model mà không cần tự viết cặp prop và event. Type của model nên phản ánh giá trị mà component thực sự đọc và ghi.

<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
 
<template>
  <input v-model="model" type="text" />
</template>

Two-way binding tiện dụng nhưng làm luồng dữ liệu khó quan sát hơn khi dùng ở nhiều tầng. Với component nghiệp vụ, hãy cân nhắc dùng props bất biến và event rõ nghĩa như submit, reset hoặc update:filters, thay vì cho mọi thứ đi qua một model duy nhất.

8. Kiểm thử contract thay vì implementation

Test component nên tập trung vào contract quan sát được: với props nào thì component hiển thị gì, khi người dùng thao tác thì event nào được phát, khi composable nhận dependency lỗi thì state chuyển sang đâu. Đừng kiểm tra chi tiết implementation nếu caller không nhìn thấy chi tiết đó.

Một composable có loader được inject sẽ dễ test hơn một composable tự gọi fetch toàn cục. Một component có emits typed sẽ dễ kiểm tra payload hơn một component phát event với object không có schema.

import { describe, expect, it } from 'vitest'
import { useSearch } from '~/composables/useSearch'
 
 
describe('useSearch', () => {
  it('exposes typed results from the injected loader', async () => {
    const search = useSearch(async (query: string) => [
      { id: query, name: 'Ada' },
    ])
 
    await search.search('ada')
 
    expect(search.results.value).toEqual([{ id: 'ada', name: 'Ada' }])
  })
})

Test trên vẫn cần môi trường Vue Test Utils hoặc lifecycle phù hợp nếu composable dùng hook. Điểm chính là dependency injection giữ cho test kiểm soát được dữ liệu đầu vào và không phụ thuộc mạng thật.

9. Những lỗi TypeScript thường gặp trong Vue

LỗiVì sao nguy hiểmCách xử lý
Dùng any cho props hoặc API responseMất kiểm tra contract ngay tại componentDùng unknown, schema runtime và type guard.
Ép kiểu as cho mọi template refCó thể che giấu element sai hoặc nullDùng useTemplateRef, optional chaining và guard.
Trộn runtime props với type-based propsContract bị lặp hoặc mâu thuẫnChọn một hình thức cho mỗi component.
Biến loading, error, data độc lậpCó thể tạo trạng thái không hợp lệDùng discriminated union.
Expose toàn bộ ref nội bộ từ composableCaller có thể phá invariantTrả readonly ref và command function.
Inject bằng string keyDễ xung đột và mất typeDùng InjectionKey<T> trong module riêng.
Watch mọi object sâu mặc địnhTạo công việc runtime và khó đoán triggerWatch đúng nguồn dữ liệu cần thiết, ưu tiên getter nhỏ.

10. Checklist trước khi merge một component Vue

Một component sẵn sàng merge khi props, emits và model đã có contract rõ; state dùng union khi có nhiều trạng thái loại trừ; composable trả ra đúng mức abstraction; listener, timer và request được cleanup; runtime data từ API được validate; và test tập trung vào hành vi quan sát được.

Nếu component vẫn cần nhiều assertion, nhiều boolean liên quan hoặc một danh sách event rất dài, hãy coi đó là tín hiệu để xem lại boundary. TypeScript không chỉ giúp sửa lỗi cú pháp; nó giúp nhận ra nơi thiết kế đang thiếu một mô hình dữ liệu tốt hơn.

Tài liệu tham khảo

  1. Vue — TypeScript with Composition API
  2. Vue — Composition API FAQ
  3. Vue — Composables
  4. Vue — Provide / Inject
  5. TypeScript Handbook — Narrowing
  6. TypeScript Handbook — More on Functions