Vue 3 với TypeScript năm 2026: Props, Emits và Composable an toàn kiểu
Làm chủ component Vue 3 an toàn kiểu với TypeScript: defineProps generic, defineEmits dạng tuple, composable có kiểu, defineModel và InjectionKey, kèm câu hỏi phỏng vấn.

Vue 3 kết hợp TypeScript vào năm 2026 mang lại cho các component khả năng kiểm tra kiểu tĩnh đầy đủ trên props, sự kiện và logic tái sử dụng. Cú pháp <script setup> cùng với các macro của compiler như defineProps và defineEmits biến những định nghĩa đối tượng vốn chỉ được kiểm tra lúc chạy thành các hợp đồng tại thời điểm biên dịch. Hướng dẫn này trình bày những mẫu an toàn kiểu mà mọi lập trình viên Vue cần nắm cho cả mã sản phẩm lẫn phỏng vấn kỹ thuật.
Trên Vue 3.5+, defineProps<T>() dạng generic suy luận kiểu prop trực tiếp từ một interface TypeScript — không cần field type lúc chạy, không cần ép kiểu PropType. Kết hợp với reactive props destructure, giá trị mặc định được viết ngay tại chỗ: const { size = 'md' } = defineProps<Props>().
Khai báo kiểu cho props bằng defineProps và interface TypeScript
Cách khai báo prop lúc chạy (props: { title: String }) vẫn hoạt động, nhưng nó nhân đôi thông tin kiểu và mất đi độ chính xác. Kiểu union, đối tượng lồng nhau và chữ ký hàm không thể được diễn đạt một cách gọn gàng. Dạng generic của defineProps giải quyết điều này bằng cách lấy hình dạng từ một kiểu TypeScript, sau đó compiler của Vue tự động chuyển đổi thành khai báo lúc chạy chính xác.
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
userId: number // required primitive
name: string
role: 'admin' | 'member' // union type, impossible with runtime props
tags?: string[] // optional array
onSelect?: (id: number) => void // typed callback prop
}
const props = defineProps<Props>()
</script>
<template>
<article @click="props.onSelect?.(props.userId)">
<h3>{{ props.name }}</h3>
<span>{{ props.role }}</span>
</article>
</template>Prop role giờ đây bị ràng buộc trong hai chuỗi ký tự literal, nên việc truyền role="guest" sẽ khiến quá trình build thất bại. Đây chính là lý do cốt lõi để chọn defineProps với TypeScript: hướng dẫn TypeScript chính thức của Vue xem dạng generic là mặc định cho các dự án dùng <script setup>.
Giá trị mặc định cho prop: withDefaults so với reactive destructure
Props chỉ có kiểu tự thân không có cơ chế giá trị mặc định lúc chạy. Trước đây câu trả lời là withDefaults, nó bao bọc macro và gộp một đối tượng chứa giá trị mặc định:
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}
// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
})
</script>Vue 3.5 đã ổn định hóa reactive props destructure, hiện là mẫu gọn gàng hơn. Việc destructure kết quả của defineProps và gán giá trị mặc định trong cùng một câu lệnh vẫn giữ tính phản ứng đầy đủ — compiler viết lại mỗi lần truy cập thành props.x phía sau hậu trường.
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
label: string
color?: 'green' | 'red'
outlined?: boolean
}
// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>
<template>
<span :class="[color, { outlined }]">{{ label }}</span>
</template>Props được destructure vẫn giữ tính phản ứng trong template và trong computed, nhưng việc truyền một giá trị đã destructure thẳng vào watch hoặc vào một composable sẽ đọc nó một lần và cắt đứt liên kết phản ứng. Hãy bọc nó trong một getter — watch(() => color, ...) — hoặc chuyển đổi bằng toRef(props, 'color') khi cần một ref ở phía sau.
Đây là một cái bẫy phỏng vấn phổ biến: ứng viên tưởng rằng biến được destructure là một giá trị thuần túy, trong khi thực chất compiler đã đấu nối lại mỗi lần đọc về props.color.
Emits an toàn kiểu với defineEmits
Sự kiện xứng đáng được xử lý chặt chẽ như props. defineEmits dạng generic mô tả mỗi tên sự kiện và payload của nó dưới dạng một tuple, mang lại cho component cha khả năng tự hoàn thành và cho component con việc kiểm tra tại thời điểm biên dịch rằng đúng đối số đã được phát ra.
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
search: [query: string]
clear: [] // no payload
select: [id: number, label: string] // multiple args
}>()
function onSubmit(value: string) {
emit('search', value) // ✅ typed
// emit('search', 42) // ❌ compile error: number not assignable to string
}
</script>Dạng tuple này đã thay thế cú pháp chữ ký lời gọi cũ ((e: 'search', q: string): void) vì nó dễ đọc hơn và hỗ trợ nhiều sự kiện mà không cần overload. Khi một sự kiện không mang dữ liệu, tuple rỗng [] ghi rõ điều đó một cách tường minh. Kết hợp emits có kiểu với props có kiểu tạo ra những component mà toàn bộ giao diện công khai có thể kiểm chứng trước khi chạy — cùng một kỷ luật được đề cập trong hướng dẫn Vue composition API.
Khai báo kiểu cho composable dùng cho logic tái sử dụng
Composable là các hàm thông thường, nên chúng tuân theo các quy tắc TypeScript bình thường — nhưng vài quy ước giúp chúng dễ dùng hơn. Hãy trả về kiểu Ref một cách tường minh khi việc suy luận không rõ ràng, và dùng generic khi một composable bao bọc dữ liệu tùy ý như một phản hồi API.
import { ref, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
}
// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(true)
fetch(url)
.then((r) => r.json())
.then((json: T) => { data.value = json })
.catch((e: Error) => { error.value = e })
.finally(() => { loading.value = false })
return { data, error, loading }
}Tại nơi gọi, tham số generic khiến data có kiểu đầy đủ mà không cần chú thích thêm:
interface User { id: number; name: string }
// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')Việc chú thích tường minh đối tượng trả về (UseFetchReturn<T>) đáng để bỏ ra vài dòng thêm: nó ghi lại hợp đồng, ngăn rò rỉ tình cờ các ref nội bộ, và cho người dùng một kiểu duy nhất để import. Về các mẫu composable sâu hơn như overload đối số và dọn dẹp có nhận biết vòng đời, hãy xem hướng dẫn composable Vue nâng cao.
Sẵn sàng chinh phục phỏng vấn Vue.js / Nuxt.js?
Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.
Khai báo kiểu cho defineModel dùng cho two-way binding
defineModel, ổn định kể từ Vue 3.4, gộp cặp prop modelValue cũ và emit update:modelValue thành một ref có thể ghi duy nhất. Tham số generic của nó khai báo kiểu cho cả hai chiều của ràng buộc cùng lúc.
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })
function increment() {
model.value++ // writing back updates the parent
}
</script>
<template>
<input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
<button @click="increment">+1</button>
</template>Model có tên — defineModel<string>('title') — ánh xạ tới v-model:title và nhận cùng cách khai báo kiểu. Điều này loại bỏ cả một lớp lỗi payload không khớp mà mẫu prop-và-emit thủ công từng che giấu.
Khai báo kiểu cho template ref và instance của component
Truy cập một nút DOM hoặc một component con thông qua một ref là nơi mã Vue không có kiểu thường rơi về any nhất. Cách khắc phục là tham số hóa useTemplateRef (Vue 3.5+) hoặc chính ref với kiểu phần tử, để việc truy cập thuộc tính được kiểm tra dựa trên giao diện DOM thực tế.
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')
onMounted(() => {
// .focus() and .select() are known because the element type is explicit
inputRef.value?.focus()
})
</script>
<template>
<input ref="field" placeholder="Type to search" />
</template>Đối với một tham chiếu tới một component con, InstanceType<typeof Child> trích xuất kiểu công khai của component đó, phơi bày bất cứ gì component con đã khai báo qua defineExpose. Điều này giữ cho các lời gọi phương thức từ cha đến con được kiểm tra đầy đủ thay vì phải phỏng đoán.
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'
// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')
function launch() {
modal.value?.open() // ✅ checked against Modal's defineExpose
}
</script>Provide và inject an toàn kiểu với InjectionKey
Việc tiêm phụ thuộc xuyên qua cây component sẽ mất thông tin kiểu trừ khi key mang theo nó. InjectionKey<T> là một symbol có kiểu ràng buộc kiểu của một giá trị với key của nó, để provide và inject luôn đồng bộ mà không cần ép kiểu thủ công.
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })
// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()Cung cấp một giá trị có hình dạng không khớp với ThemeContext là một lỗi biên dịch ngay tại nơi tiêm, bắt được sự lệch pha giữa các component ở xa nhau trước khi phát hành.
Các câu hỏi phỏng vấn TypeScript Vue thường gặp
Người phỏng vấn thăm dò xem ứng viên có hiểu ranh giới giữa kiểu tại thời điểm biên dịch và hành vi lúc chạy hay không. Vài câu hỏi thường lặp lại cùng câu trả lời sắc bén:
- Vì sao nên ưu tiên
defineProps<T>()hơn dạng đối tượng lúc chạy? Props generic có thể diễn đạt kiểu union, chữ ký hàm và hình dạng lồng nhau mà khai báo lúc chạy không làm được, đồng thời loại bỏ việc trùng lặp định nghĩa kiểu/lúc chạy. - Props được destructure có phản ứng không? Có, trên Vue 3.5+, vì compiler viết lại truy cập thành
props.x. Nhưng giá trị đã destructure được truyền vàowatchhoặc vào một composable chỉ được đọc một lần — hãy dùng getter hoặctoRef. - Làm sao khai báo kiểu cho một sự kiện phát ra không có payload? Một tuple rỗng:
defineEmits<{ close: [] }>(). defineModelthay thế cái gì? Cặp propmodelValuevà emitupdate:modelValue, hợp nhất thành một ref có thể ghi có kiểu.
Luyện tập những điều này với một ngân hàng câu hỏi thực tế mài giũa phản xạ mà phỏng vấn tưởng thưởng — mô-đun phỏng vấn composable Vue rèn luyện chính xác những mẫu này. Công cụ cũng quan trọng: chạy vue-tsc trong CI để lỗi kiểu chặn việc merge, và dựa vào sổ tay TypeScript khi generic của composable bắt đầu tham gia. Trải nghiệm trình soạn thảo được cung cấp bởi công cụ Volar chính thức của Vue, vốn đọc các macro này để hiển thị lỗi ngay tại chỗ.
Kết luận
- Dùng
defineProps<Props>()dạng generic để diễn đạt kiểu union, field tùy chọn và prop callback mà khai báo lúc chạy không nắm bắt được. - Ưu tiên reactive props destructure với giá trị mặc định tại chỗ trên Vue 3.5+, và chỉ dùng đến
withDefaultskhi một đối tượng giá trị mặc định dùng chung rõ ràng hơn. - Khai báo kiểu cho sự kiện bằng dạng tuple của
defineEmitsđể payload được kiểm tra tại thời điểm biên dịch ở cả con lẫn cha. - Chú thích tường minh kiểu trả về của composable và dùng generic để chuyển tiếp kiểu dữ liệu do người gọi cung cấp từ đầu đến cuối.
- Áp dụng
defineModel<T>()cho two-way binding để gộp mẫu boilerplate prop-cộng-emit thành một ref có kiểu. - Chạy
vue-tsctrong tích hợp liên tục để hồi quy kiểu làm thất bại quá trình build thay vì lọt tới sản phẩm.
Bắt đầu luyện tập!
Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.
Thẻ
Chia sẻ
Bài viết liên quan

Vue 3 Composables Nâng Cao: Các Pattern Tái Sử Dụng và Câu Hỏi Phỏng Vấn 2026
Phân tích chuyên sâu các composable nâng cao trong Vue 3 với pattern tái sử dụng, xử lý bất đồng bộ, dependency injection qua provide/inject, form validation và testing. Bao gồm câu hỏi phỏng vấn kỹ thuật cập nhật 2026.

Vue 3 Composition API: Hướng Dẫn Đầy Đủ Để Làm Chủ Reactivity
Làm chủ Vue 3 Composition API qua hướng dẫn thực hành này. Tìm hiểu ref, reactive, computed, watch và composables để xây dựng ứng dụng Vue hiệu suất cao.

Nuxt 4 Trong Năm 2026: Cấu Trúc Thư Mục app/ Mới Và Hướng Dẫn Di Chuyển Từ Nuxt 3
Hướng dẫn đầy đủ di chuyển từ Nuxt 3 sang Nuxt 4 với cấu trúc thư mục app/ mới, singleton data fetching, shallow reactivity mặc định, và phân tách ngữ cảnh TypeScript.