Vue 3 กับ TypeScript ในปี 2026: Props, Emits และ Composable ที่ปลอดภัยด้านชนิดข้อมูล

เชี่ยวชาญคอมโพเนนต์ Vue 3 ที่ปลอดภัยด้านชนิดข้อมูลด้วย TypeScript: defineProps แบบ generic, defineEmits แบบ tuple, composable ที่มีชนิด, defineModel และ InjectionKey พร้อมคำถามสัมภาษณ์

props, emits และ composable ที่ปลอดภัยด้านชนิดข้อมูลของ Vue 3 TypeScript

Vue 3 ที่ทำงานร่วมกับ TypeScript ในปี 2026 มอบการตรวจสอบชนิดข้อมูลแบบสแตติกอย่างสมบูรณ์ให้กับคอมโพเนนต์ทั้งในส่วนของ props, event และตรรกะที่นำกลับมาใช้ซ้ำได้ ไวยากรณ์ <script setup> เมื่อรวมเข้ากับ compiler macro อย่าง defineProps และ defineEmits จะเปลี่ยนสิ่งที่เคยเป็นเพียงการนิยามอ็อบเจกต์ที่ตรวจสอบได้ตอนรันไทม์เท่านั้น ให้กลายเป็นสัญญา (contract) ที่ตรวจสอบได้ตั้งแต่ตอนคอมไพล์ คู่มือนี้ครอบคลุมรูปแบบที่ปลอดภัยด้านชนิดข้อมูลซึ่งนักพัฒนา Vue ทุกคนจำเป็นต้องรู้ ทั้งสำหรับโค้ดที่ใช้งานจริงและการสัมภาษณ์เชิงเทคนิค

props ที่ปลอดภัยด้านชนิดข้อมูลในบรรทัดเดียว

ใน Vue 3.5+ นั้น defineProps<T>() แบบ generic จะอนุมานชนิดของ prop โดยตรงจาก interface ของ TypeScript โดยไม่ต้องมี field type ตอนรันไทม์ และไม่ต้อง cast PropType เมื่อรวมกับ reactive props destructure ค่าเริ่มต้นสามารถเขียนแบบ inline ได้ทันที: const { size = 'md' } = defineProps<Props>()

การกำหนดชนิดให้ props ด้วย defineProps และ interface ของ TypeScript

การประกาศ prop แบบรันไทม์ (props: { title: String }) นั้นใช้งานได้ แต่มันทำให้ข้อมูลชนิดซ้ำซ้อนและสูญเสียความแม่นยำ ทั้ง union type, อ็อบเจกต์ซ้อน และลายเซ็นของฟังก์ชันไม่สามารถแสดงออกได้อย่างสะอาดตา รูปแบบ generic ของ defineProps แก้ปัญหานี้ด้วยการรับโครงสร้างจากชนิดของ TypeScript ซึ่ง compiler ของ Vue จะแปลงเป็นการประกาศแบบรันไทม์ที่ถูกต้องให้โดยอัตโนมัติ

vue
<!-- 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 ถูกจำกัดให้เป็นสตริง literal เพียงสองค่า ดังนั้นการส่ง role="guest" จะทำให้กระบวนการ build ล้มเหลว นี่คือเหตุผลหลักที่ควรเลือกใช้ defineProps ร่วมกับ TypeScript โดย คู่มือ TypeScript อย่างเป็นทางการของ Vue ถือว่ารูปแบบ generic เป็นค่าเริ่มต้นสำหรับโปรเจกต์ที่ใช้ <script setup>

ค่าเริ่มต้นของ prop: withDefaults เทียบกับ reactive destructure

props ที่มีเพียงชนิดข้อมูลนั้นไม่มีกลไกค่าเริ่มต้นตอนรันไทม์ในตัวเอง ในอดีตคำตอบคือ withDefaults ซึ่งห่อหุ้ม macro และรวมอ็อบเจกต์ค่าเริ่มต้นเข้าด้วยกัน:

vue
<!-- 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 ได้ทำให้ reactive props destructure มีเสถียรภาพ ซึ่งปัจจุบันเป็นรูปแบบที่กระชับกว่า การ destructure ผลลัพธ์ของ defineProps และกำหนดค่าเริ่มต้นในคำสั่งเดียวกันยังคงความ reactive อย่างสมบูรณ์ เพราะ compiler จะเขียนการเข้าถึงแต่ละครั้งกลับไปเป็น props.x อยู่เบื้องหลัง

vue
<!-- 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>
กับดักด้าน reactivity ของ props ที่ถูก destructure

props ที่ถูก destructure ยังคงความ reactive ทั้งในเทมเพลตและใน computed แต่การส่งค่าที่ถูก destructure โดยตรงเข้าไปใน watch หรือใน composable จะอ่านค่านั้นเพียงครั้งเดียวและตัดการเชื่อมโยงแบบ reactive ทิ้งไป ให้ห่อไว้ใน getter — watch(() => color, ...) — หรือแปลงด้วย toRef(props, 'color') เมื่อจำเป็นต้องใช้ ref ในขั้นตอนถัดไป

นี่คือกับดักการสัมภาษณ์ที่พบได้บ่อย: ผู้สมัครมักเข้าใจว่าตัวแปรที่ถูก destructure เป็นค่าธรรมดา ทั้งที่จริงแล้ว compiler ได้เดินสายการอ่านทุกครั้งกลับไปยัง props.color แล้ว

emits ที่ปลอดภัยด้านชนิดข้อมูลด้วย defineEmits

event สมควรได้รับความเข้มงวดเช่นเดียวกับ props รูปแบบ generic ของ defineEmits อธิบายชื่อ event แต่ละตัวและ payload ของมันในรูปของ tuple ทำให้คอมโพเนนต์แม่ได้รับการเติมข้อความอัตโนมัติ และคอมโพเนนต์ลูกได้รับการตรวจสอบตอนคอมไพล์ว่าได้ส่งอาร์กิวเมนต์ที่ถูกต้องออกไป

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

รูปแบบ tuple นี้เข้ามาแทนที่ไวยากรณ์ลายเซ็นการเรียกแบบเดิม ((e: 'search', q: string): void) เพราะอ่านได้ง่ายกว่าและรองรับหลาย event โดยไม่ต้องใช้ overload เมื่อ event ไม่มีข้อมูลติดไปด้วย tuple เปล่า [] จะบันทึกเรื่องนี้ไว้อย่างชัดเจน การจับคู่ emits ที่มีชนิดข้อมูลเข้ากับ props ที่มีชนิดข้อมูลจะสร้างคอมโพเนนต์ที่อินเทอร์เฟซสาธารณะทั้งหมดสามารถตรวจสอบได้ก่อนรันไทม์ ซึ่งเป็นวินัยเดียวกับที่กล่าวถึงใน คู่มือ Vue composition API

การกำหนดชนิดให้ composable สำหรับตรรกะที่นำกลับมาใช้ซ้ำ

composable เป็นเพียงฟังก์ชันธรรมดา จึงเป็นไปตามกฎทั่วไปของ TypeScript แต่ธรรมเนียมปฏิบัติบางอย่างช่วยให้ใช้งานได้สะดวก ควรคืนค่าชนิด Ref อย่างชัดเจนเมื่อการอนุมานไม่ชัดเจน และใช้ generic เมื่อ composable ห่อหุ้มข้อมูลใดก็ได้ เช่น การตอบกลับจาก API

useFetch.tstypescript
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 }
}

ณ จุดที่เรียกใช้ พารามิเตอร์ generic ทำให้ data มีชนิดข้อมูลครบถ้วนโดยไม่ต้องใส่คำอธิบายประกอบเพิ่มเติม:

UserList.vue (script setup)typescript
interface User { id: number; name: string }

// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')

การใส่คำอธิบายประกอบให้อ็อบเจกต์ที่คืนค่าอย่างชัดเจน (UseFetchReturn<T>) คุ้มค่ากับโค้ดที่เพิ่มขึ้นไม่กี่บรรทัด เพราะมันบันทึกสัญญาไว้ ป้องกันการรั่วไหลของ ref ภายในโดยไม่ตั้งใจ และมอบชนิดข้อมูลชนิดเดียวให้ผู้ใช้นำเข้าไปใช้ สำหรับรูปแบบ composable ที่ลึกกว่านี้ เช่น การ overload อาร์กิวเมนต์และการล้างข้อมูลที่รับรู้วงจรชีวิต โปรดดู คู่มือ composable ของ Vue ขั้นสูง

พร้อมที่จะพิชิตการสัมภาษณ์ Vue.js / Nuxt.js แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

การกำหนดชนิดให้ defineModel สำหรับ two-way binding

defineModel ซึ่งมีเสถียรภาพตั้งแต่ Vue 3.4 ยุบคู่ของ prop modelValue เดิมและ emit update:modelValue ให้เหลือเป็น ref ที่เขียนได้เพียงตัวเดียว พารามิเตอร์ generic ของมันกำหนดชนิดให้ทั้งสองทิศทางของ binding พร้อมกันในคราวเดียว

vue
<!-- 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 ที่มีชื่อ — defineModel<string>('title') — จะจับคู่กับ v-model:title และได้รับการกำหนดชนิดแบบเดียวกัน สิ่งนี้ขจัดบั๊กประเภท payload ที่ไม่ตรงกันทั้งกลุ่ม ซึ่งรูปแบบ prop-กับ-emit แบบทำมือเคยซ่อนเอาไว้

การกำหนดชนิดให้ template ref และอินสแตนซ์ของคอมโพเนนต์

การเข้าถึงโหนด DOM หรือคอมโพเนนต์ลูกผ่าน ref คือจุดที่โค้ด Vue ที่ไม่มีชนิดข้อมูลมักตกกลับไปเป็น any มากที่สุด วิธีแก้คือการใส่พารามิเตอร์ชนิดขององค์ประกอบให้กับ useTemplateRef (Vue 3.5+) หรือให้กับ ref เอง เพื่อให้การเข้าถึงพร็อพเพอร์ตีถูกตรวจสอบกับอินเทอร์เฟซ DOM ที่แท้จริง

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

สำหรับการอ้างอิงไปยังคอมโพเนนต์ลูก InstanceType<typeof Child> จะสกัดชนิดสาธารณะของคอมโพเนนต์นั้นออกมา เปิดเผยทุกสิ่งที่ลูกประกาศไว้ผ่าน defineExpose สิ่งนี้ทำให้การเรียกเมธอดจากแม่ไปยังลูกถูกตรวจสอบอย่างครบถ้วนแทนที่จะต้องเดา

vue
<!-- 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 และ inject ที่ปลอดภัยด้านชนิดข้อมูลด้วย InjectionKey

การฉีดพึ่งพา (dependency injection) ข้ามต้นไม้ของคอมโพเนนต์จะสูญเสียข้อมูลชนิด เว้นแต่ว่า key จะนำมันติดไปด้วย InjectionKey<T> คือ symbol ที่มีชนิดข้อมูลซึ่งผูกชนิดของค่าเข้ากับ key ของมัน เพื่อให้ provide และ inject สอดคล้องกันโดยไม่ต้อง cast ด้วยมือ

theme-key.tstypescript
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')
typescript
// 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()

การจัดหาค่าที่โครงสร้างไม่ตรงกับ ThemeContext จะเป็นข้อผิดพลาดตอนคอมไพล์ ณ จุดที่ฉีด ช่วยดักจับความคลาดเคลื่อนระหว่างคอมโพเนนต์ที่อยู่ห่างไกลกันก่อนที่จะปล่อยขึ้นใช้งานจริง

คำถามสัมภาษณ์ TypeScript ของ Vue ที่พบบ่อย

ผู้สัมภาษณ์มักตรวจสอบว่าผู้สมัครเข้าใจเส้นแบ่งระหว่างชนิดข้อมูลตอนคอมไพล์กับพฤติกรรมตอนรันไทม์หรือไม่ ต่อไปนี้คือคำถามที่พบซ้ำ ๆ พร้อมคำตอบที่คมชัด:

  • เหตุใดจึงควรเลือก defineProps<T>() มากกว่ารูปแบบอ็อบเจกต์แบบรันไทม์? props แบบ generic สามารถแสดง union type, ลายเซ็นฟังก์ชัน และโครงสร้างแบบซ้อนที่การประกาศแบบรันไทม์ทำไม่ได้ อีกทั้งยังขจัดการนิยามชนิด/รันไทม์ที่ซ้ำซ้อน
  • props ที่ถูก destructure ยังคง reactive หรือไม่? ใช่ ใน Vue 3.5+ เพราะ compiler เขียนการเข้าถึงกลับไปเป็น props.x แต่ ค่า ที่ถูก destructure แล้วส่งเข้าไปใน watch หรือใน composable จะถูกอ่านเพียงครั้งเดียว ให้ใช้ getter หรือ toRef
  • จะกำหนดชนิดให้ event ที่ส่งออกโดยไม่มี payload อย่างไร? ใช้ tuple เปล่า: defineEmits<{ close: [] }>()
  • defineModel เข้ามาแทนที่อะไร? คู่ของ prop modelValue และ emit update:modelValue ที่ถูกรวมเป็น ref ที่เขียนได้และมีชนิดข้อมูลเพียงตัวเดียว

การฝึกฝนสิ่งเหล่านี้กับคลังคำถามจริงจะช่วยลับปฏิกิริยาตอบสนองที่การสัมภาษณ์ให้ค่า — โมดูลสัมภาษณ์ composable ของ Vue ฝึกฝนรูปแบบเหล่านี้อย่างตรงจุด เครื่องมือก็สำคัญเช่นกัน: รัน vue-tsc ใน CI เพื่อให้ข้อผิดพลาดด้านชนิดข้อมูลปิดกั้นการ merge และพึ่งพา คู่มือ TypeScript เมื่อ generic ของ composable เริ่มเข้ามาเกี่ยวข้อง ประสบการณ์การใช้งานในเอดิเตอร์ขับเคลื่อนโดย เครื่องมือ Volar อย่างเป็นทางการของ Vue ซึ่งอ่าน macro เหล่านี้เพื่อแสดงข้อผิดพลาดแบบ inline

บทสรุป

  • ใช้ defineProps<Props>() แบบ generic เพื่อแสดง union type, field ที่เป็นทางเลือก และ prop แบบ callback ที่การประกาศแบบรันไทม์ไม่สามารถจับต้องได้
  • ให้ความสำคัญกับ reactive props destructure ที่มีค่าเริ่มต้นแบบ inline ใน Vue 3.5+ และหันไปใช้ withDefaults เฉพาะเมื่ออ็อบเจกต์ค่าเริ่มต้นที่ใช้ร่วมกันชัดเจนกว่า
  • กำหนดชนิดให้ event ด้วยรูปแบบ tuple ของ defineEmits เพื่อให้ payload ถูกตรวจสอบตอนคอมไพล์ทั้งในลูกและในแม่
  • ใส่คำอธิบายประกอบชนิดที่คืนค่าของ composable อย่างชัดเจน และใช้ generic เพื่อส่งต่อชนิดข้อมูลที่ผู้เรียกจัดหาให้ตั้งแต่ต้นจนจบ
  • นำ defineModel<T>() มาใช้กับ two-way binding เพื่อยุบโค้ด boilerplate แบบ prop-บวก-emit ให้เหลือเป็น ref ที่มีชนิดข้อมูลตัวเดียว
  • รัน vue-tsc ในระบบการรวมโค้ดแบบต่อเนื่อง เพื่อให้การถดถอยด้านชนิดข้อมูลทำให้การ build ล้มเหลว แทนที่จะหลุดไปถึงการใช้งานจริง

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

แชร์

บทความที่เกี่ยวข้อง

Vue 3 Composables Advanced Patterns

Vue 3 Composables ขั้นสูง: รูปแบบการใช้ซ้ำและคำถามสัมภาษณ์งาน 2026

คู่มือ Vue 3 Composables ขั้นสูงฉบับสมบูรณ์: รูปแบบการใช้ซ้ำ การจัดการ Async การ Inject Dependencies การตรวจสอบฟอร์ม และคำถามสัมภาษณ์งานเทคนิคปี 2026

ภาพประกอบ Vue 3 Composition API พร้อมบล็อกโค้ด reactive ที่เชื่อมต่อกัน

Vue 3 Composition API: คู่มือฉบับสมบูรณ์เพื่อเชี่ยวชาญระบบ Reactivity

เชี่ยวชาญ Vue 3 Composition API ผ่านคู่มือเชิงปฏิบัตินี้ เรียนรู้ ref, reactive, computed, watch และ composables เพื่อสร้างแอปพลิเคชัน Vue ที่มีประสิทธิภาพสูง

Nuxt 4 directory structure migration guide

Nuxt 4 ในปี 2026: โครงสร้างไดเรกทอรีใหม่และคู่มือการย้ายจาก Nuxt 3

คู่มือการย้ายจาก Nuxt 3 ไปยัง Nuxt 4 ฉบับสมบูรณ์ ครอบคลุมโครงสร้างไดเรกทอรี app/ ใหม่ singleton data fetching, shallow reactivity และการแยกบริบท TypeScript พร้อมตัวอย่างโค้ดจริง