Vue 3 dengan TypeScript di 2026: Props, Emits, dan Composable Aman-Tipe

Kuasai komponen Vue 3 yang aman-tipe dengan TypeScript: defineProps generik, defineEmits tuple, composable bertipe, defineModel, dan InjectionKey, plus soal wawancara.

Vue 3 TypeScript props, emits, dan composable yang aman-tipe

Vue 3 dengan TypeScript pada tahun 2026 memberikan komponen pemeriksaan tipe statis penuh untuk props, event, dan logika yang dapat digunakan ulang. Sintaks <script setup> yang dipadukan dengan macro compiler seperti defineProps dan defineEmits mengubah definisi objek yang dulunya hanya diperiksa saat runtime menjadi kontrak pada waktu kompilasi. Panduan ini membahas pola aman-tipe yang perlu dikuasai setiap pengembang Vue untuk kode produksi maupun wawancara teknis.

Props aman-tipe dalam satu baris

Pada Vue 3.5+, defineProps<T>() generik menyimpulkan tipe prop langsung dari sebuah interface TypeScript — tanpa field type runtime, tanpa casting PropType. Dipadukan dengan reactive props destructure, nilai bawaan ditulis secara inline: const { size = 'md' } = defineProps<Props>().

Mengetik props dengan defineProps dan interface TypeScript

Deklarasi prop runtime (props: { title: String }) memang berfungsi, tetapi menduplikasi informasi tipe dan kehilangan presisi. Tipe union, objek bersarang, dan tanda tangan fungsi tidak dapat diekspresikan dengan bersih. Bentuk generik defineProps menyelesaikan masalah ini dengan mengambil bentuk dari sebuah tipe TypeScript, yang kemudian dikonversi compiler Vue menjadi deklarasi runtime yang benar secara otomatis.

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 kini dibatasi pada dua string literal, sehingga meneruskan role="guest" akan menggagalkan proses build. Inilah alasan utama untuk memilih defineProps dengan TypeScript: panduan resmi Vue TypeScript memperlakukan bentuk generik sebagai standar untuk proyek <script setup>.

Nilai bawaan prop: withDefaults vs reactive destructure

Props bertipe-saja tidak memiliki mekanisme nilai bawaan runtime dengan sendirinya. Secara historis jawabannya adalah withDefaults, yang membungkus macro dan menggabungkan objek nilai bawaan:

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 menstabilkan reactive props destructure, yang kini menjadi pola yang lebih ringkas. Melakukan destructure pada hasil defineProps dan menetapkan nilai bawaan dalam pernyataan yang sama tetap sepenuhnya reaktif — compiler menuliskan ulang setiap akses kembali menjadi props.x di balik layar.

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>
Jebakan reaktivitas pada props yang di-destructure

Props yang di-destructure tetap reaktif di dalam template dan di computed, tetapi meneruskan nilai hasil destructure langsung ke watch atau ke sebuah composable akan membacanya sekali saja dan memutus tautan reaktifnya. Bungkus dengan getter — watch(() => color, ...) — atau konversikan dengan toRef(props, 'color') ketika sebuah ref dibutuhkan di bagian selanjutnya.

Ini adalah jebakan wawancara yang umum: kandidat mengira variabel hasil destructure adalah nilai biasa, padahal compiler sebenarnya telah mengarahkan ulang setiap pembacaan kembali ke props.color.

Emits aman-tipe dengan defineEmits

Event layak mendapat ketelitian yang sama seperti props. defineEmits generik mendeskripsikan setiap nama event dan payload-nya sebagai sebuah tuple, memberikan komponen induk pelengkapan otomatis dan komponen anak pemeriksaan pada waktu kompilasi bahwa argumen yang tepat telah dipancarkan.

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>

Bentuk tuple ini menggantikan sintaks tanda tangan panggilan yang lebih lama ((e: 'search', q: string): void) karena lebih mudah dibaca dan mendukung banyak event tanpa overload. Ketika sebuah event tidak membawa data, tuple kosong [] mendokumentasikannya secara eksplisit. Memadukan emits bertipe dengan props bertipe menghasilkan komponen yang seluruh antarmuka publiknya dapat diverifikasi sebelum runtime — disiplin yang sama seperti yang dibahas dalam panduan Vue composition API.

Mengetik composable untuk logika yang dapat digunakan ulang

Composable adalah fungsi biasa, jadi mereka mengikuti aturan TypeScript umum — tetapi beberapa konvensi menjaganya tetap ergonomis. Kembalikan tipe Ref secara eksplisit ketika inferensi tidak jelas, dan gunakan generik ketika sebuah composable membungkus data sembarang seperti respons 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 }
}

Di titik pemanggilan, parameter generik membuat data sepenuhnya bertipe tanpa anotasi tambahan:

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

Menganotasi objek yang dikembalikan secara eksplisit (UseFetchReturn<T>) sepadan dengan beberapa baris tambahan: hal itu mendokumentasikan kontrak, mencegah kebocoran ref internal yang tidak disengaja, dan memberi konsumen satu tipe untuk diimpor. Untuk pola composable yang lebih mendalam seperti overload argumen dan pembersihan yang sadar-siklus-hidup, lihat panduan composable Vue tingkat lanjut.

Siap menguasai wawancara Vue.js / Nuxt.js Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Mengetik defineModel untuk two-way binding

defineModel, stabil sejak Vue 3.4, menyatukan pasangan prop modelValue lama dan emit update:modelValue menjadi satu ref yang dapat ditulis. Parameter generiknya mengetik kedua arah binding sekaligus.

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 bernama — defineModel<string>('title') — dipetakan ke v-model:title dan menerima pengetikan yang sama. Ini menghilangkan seluruh kelas bug payload yang tidak cocok, yang dulu disembunyikan oleh pola prop-dan-emit manual.

Mengetik template ref dan instance komponen

Mengakses sebuah node DOM atau komponen anak melalui sebuah ref adalah tempat kode Vue tanpa tipe paling sering jatuh ke any. Perbaikannya adalah memberikan parameter tipe elemen pada useTemplateRef (Vue 3.5+) atau pada ref itu sendiri, sehingga akses properti diperiksa terhadap antarmuka DOM yang sebenarnya.

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>

Untuk referensi ke komponen anak, InstanceType<typeof Child> mengekstrak tipe publik komponen tersebut, mengekspos apa pun yang dideklarasikan anak melalui defineExpose. Ini menjaga panggilan metode induk-ke-anak tetap terperiksa penuh alih-alih ditebak.

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 dan inject aman-tipe dengan InjectionKey

Injeksi dependensi melintasi pohon komponen kehilangan informasi tipe kecuali key-nya membawanya. InjectionKey<T> adalah sebuah symbol bertipe yang mengikat tipe sebuah nilai ke key-nya, sehingga provide dan inject tetap selaras tanpa casting manual.

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

Menyediakan sebuah nilai yang bentuknya tidak cocok dengan ThemeContext adalah kesalahan kompilasi di titik injeksi, menangkap ketidakcocokan antar komponen yang berjauhan sebelum dirilis.

Pertanyaan wawancara TypeScript Vue yang umum

Pewawancara menyelidiki apakah seorang kandidat memahami batas antara tipe waktu-kompilasi dan perilaku runtime. Beberapa pertanyaan yang sering muncul beserta jawaban tajamnya:

  • Mengapa memilih defineProps<T>() dibanding bentuk objek runtime? Props generik dapat mengekspresikan tipe union, tanda tangan fungsi, dan bentuk bersarang yang tidak dapat dilakukan deklarasi runtime, serta menghilangkan duplikasi definisi tipe/runtime.
  • Apakah props hasil destructure bersifat reaktif? Ya pada Vue 3.5+, karena compiler menulis ulang akses menjadi props.x. Namun nilai hasil destructure yang diteruskan ke watch atau ke sebuah composable dibaca sekali saja — gunakan getter atau toRef.
  • Bagaimana cara mengetik event yang dipancarkan tanpa payload? Tuple kosong: defineEmits<{ close: [] }>().
  • Apa yang digantikan defineModel? Pasangan prop modelValue dan emit update:modelValue, disatukan menjadi satu ref bertipe yang dapat ditulis.

Melatih hal-hal ini dengan bank soal nyata mengasah refleks yang dihargai wawancara — modul wawancara composable Vue melatih pola-pola ini secara persis. Perkakas juga penting: jalankan vue-tsc di CI agar kesalahan tipe memblokir merge, dan andalkan handbook TypeScript saat generik composable mulai terlibat. Pengalaman editor didukung oleh perkakas resmi Volar milik Vue, yang membaca macro-macro ini untuk menampilkan kesalahan secara inline.

Kesimpulan

  • Gunakan defineProps<Props>() generik untuk mengekspresikan tipe union, field opsional, dan prop callback yang tidak dapat ditangkap deklarasi runtime.
  • Utamakan reactive props destructure dengan nilai bawaan inline pada Vue 3.5+, dan gunakan withDefaults hanya ketika objek nilai bawaan bersama lebih jelas.
  • Ketik event dengan bentuk tuple defineEmits agar payload diperiksa pada waktu kompilasi baik di anak maupun di induk.
  • Anotasi tipe pengembalian composable secara eksplisit dan gunakan generik untuk meneruskan tipe data yang disediakan pemanggil dari ujung ke ujung.
  • Adopsi defineModel<T>() untuk two-way binding guna menyatukan boilerplate prop-plus-emit menjadi satu ref bertipe.
  • Jalankan vue-tsc dalam integrasi berkelanjutan agar regresi tipe menggagalkan build alih-alih mencapai produksi.

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tag

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Bagikan

Artikel terkait