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 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.
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.
<!-- 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:
<!-- 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.
<!-- 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 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.
<!-- 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.
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:
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.
<!-- 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.
<!-- 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.
<!-- 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.
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()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 kewatchatau ke sebuah composable dibaca sekali saja — gunakan getter atautoRef. - Bagaimana cara mengetik event yang dipancarkan tanpa payload? Tuple kosong:
defineEmits<{ close: [] }>(). - Apa yang digantikan
defineModel? Pasangan propmodelValuedan emitupdate: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
withDefaultshanya ketika objek nilai bawaan bersama lebih jelas. - Ketik event dengan bentuk tuple
defineEmitsagar 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-tscdalam integrasi berkelanjutan agar regresi tipe menggagalkan build alih-alih mencapai produksi.
Mulai berlatih!
Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.
Tag
Bagikan
Artikel terkait

Vue 3 Composables Tingkat Lanjut: Pola yang Dapat Digunakan Ulang dan Pertanyaan Interview 2026
Panduan lengkap Vue 3 composables tingkat lanjut: pola yang dapat digunakan ulang, penanganan asinkron, dependency injection, validasi form, pengujian terisolasi, dan pertanyaan interview teknis 2026.

Vue 3 Composition API: Panduan Lengkap Menguasai Reaktivitas
Kuasai Vue 3 Composition API melalui panduan praktis ini. Pelajari ref, reactive, computed, watch, dan composables untuk membangun aplikasi Vue yang berkinerja tinggi.

Nuxt 4 di Tahun 2026: Struktur Direktori Baru dan Panduan Migrasi dari Nuxt 3
Panduan lengkap Nuxt 4 mencakup struktur direktori app/, singleton data fetching, shallow reactivity, serta langkah migrasi bertahap dari Nuxt 3 untuk developer Vue.js.