Vue 3 met TypeScript in 2026: type-safe props, emits en composables
Type-safe Vue 3-componenten beheersen met TypeScript: generieke defineProps, defineEmits als tuple, getypeerde composables, defineModel en InjectionKey, plus sollicitatievragen.

Vue 3 met TypeScript levert in 2026 volledig statische typecontrole over props, events en herbruikbare logica. De <script setup>-syntaxis in combinatie met compilermacro's zoals defineProps en defineEmits verandert wat vroeger alleen tijdens runtime bestond in contracten die tijdens het compileren worden gecontroleerd. Deze gids behandelt de type-safe patronen die elke Vue-ontwikkelaar nodig heeft voor productiecode en technische sollicitatiegesprekken.
In Vue 3.5+ leidt het generieke defineProps<T>() de prop-typen rechtstreeks af uit een TypeScript-interface — geen type-velden tijdens runtime, geen PropType-casts. In combinatie met reactieve prop-destructurering staan standaardwaarden inline: const { size = 'md' } = defineProps<Props>().
Props typeren met defineProps en TypeScript-interfaces
De runtime-declaratie van props (props: { title: String }) werkt, maar dupliceert type-informatie en verliest precisie. Union-typen, geneste objecten en functiesignaturen laten zich er niet netjes mee uitdrukken. De generieke vorm van defineProps lost dit op door de vorm uit een TypeScript-type te halen, dat de Vue-compiler vervolgens automatisch omzet naar de juiste runtime-declaratie.
<!-- 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>De prop role is nu beperkt tot twee letterlijke strings, waardoor het doorgeven van role="guest" de build laat mislukken. Dat is precies de kernreden om naar defineProps met TypeScript te grijpen: de officiële Vue-TypeScript-gids beschouwt de generieke vorm als standaard voor projecten met <script setup>.
Standaardwaarden voor props: withDefaults vs. reactieve destructurering
Props die alleen uit typen bestaan, hebben op zichzelf geen runtime-mechanisme voor standaardwaarden. Historisch luidde het antwoord withDefaults, dat de macro omhult en een object met standaardwaarden samenvoegt:
<!-- 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 heeft reactieve prop-destructurering gestabiliseerd, inmiddels het beknoptere patroon. Het resultaat van defineProps destructureren en in dezelfde instructie een standaardwaarde toekennen blijft volledig reactief — de compiler herschrijft elke toegang achter de schermen terug naar props.x.
<!-- 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>Gedestructureerde props blijven reactief in het template en in computed, maar een gedestructureerde waarde rechtstreeks doorgeven aan watch of een composable leest hem eenmalig en verbreekt de reactieve koppeling. Verpak hem in dat geval in een getter — watch(() => color, ...) — of converteer hem met toRef(props, 'color') wanneer verderop een ref nodig is.
Dit is een veelvoorkomende valkuil in sollicitatiegesprekken: kandidaten gaan ervan uit dat de gedestructureerde variabele een gewone waarde is, terwijl de compiler in werkelijkheid elke leesactie heeft omgeleid naar props.color.
Type-safe events met defineEmits
Events verdienen dezelfde zorgvuldigheid als props. Het generieke defineEmits beschrijft elke eventnaam en de bijbehorende payload als een tuple, wat de oudercomponent autoaanvulling geeft en de kindcomponent controles tijdens het compileren dat de juiste argumenten worden uitgezonden.
<!-- 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>Deze tuple-vorm verving de oudere aanroepsignatuur-syntaxis ((e: 'search', q: string): void), omdat ze prettiger leest en meerdere events zonder overloads ondersteunt. Wanneer een event geen data draagt, documenteert een lege tuple [] dat expliciet. Getypeerde events combineren met getypeerde props levert componenten op waarvan de volledige publieke interface vóór runtime verifieerbaar is — dezelfde discipline die de gids over de Vue Composition API behandelt.
Composables typeren voor herbruikbare logica
Composables zijn gewone functies en volgen dus de normale TypeScript-regels — maar een paar conventies houden ze ergonomisch. Retourneer Ref-typen expliciet wanneer de inferentie niet vanzelfsprekend is, en gebruik generics wanneer een composable willekeurige data zoals een API-respons inpakt.
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 }
}Op de aanroepplek maakt de generieke parameter data volledig getypeerd zonder enige extra annotatie:
interface User { id: number; name: string }
// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')Het retourobject expliciet annoteren (UseFetchReturn<T>) is de paar extra regels waard: het documenteert het contract, voorkomt dat interne refs per ongeluk uitlekken en geeft consumenten één type om te importeren. Voor diepgaandere composable-patronen zoals argument-overloads en levenscyclusbewuste opruiming, zie de geavanceerde gids over Vue-composables.
Klaar om je Vue.js / Nuxt.js gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
defineModel typeren voor tweewegbinding
defineModel, stabiel sinds Vue 3.4, brengt de oude combinatie van de modelValue-prop plus de update:modelValue-emit samen in één schrijfbare ref. De generieke parameter typeert beide richtingen van de binding in één keer.
<!-- 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>Benoemde modellen — defineModel<string>('title') — worden toegewezen aan v-model:title en krijgen dezelfde typering. Dit elimineert een hele klasse van bugs door niet-overeenkomende payloads die het handmatige prop-en-emit-patroon vroeger verborg.
Template-refs en componentinstanties typeren
Toegang tot een DOM-knoop of een kindcomponent via een ref is de plek waar niet-getypeerde Vue-code het vaakst terugvalt op any. De oplossing is om useTemplateRef (Vue 3.5+) of de ref zelf te parametriseren met het elementtype, zodat toegang tot eigenschappen wordt gecontroleerd tegen de echte DOM-interface.
<!-- 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>Voor een verwijzing naar een kindcomponent extraheert InstanceType<typeof Child> het publieke type van de component en legt bloot wat het kind via defineExpose beschikbaar stelt. Zo blijven methodeaanroepen van ouder naar kind volledig gecontroleerd in plaats van gegokt.
<!-- 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>Type-safe provide en inject met InjectionKey
Dependency injection door de componentenboom verliest type-informatie tenzij de sleutel die meedraagt. InjectionKey<T> is een getypeerd symbol dat het type van een waarde aan zijn sleutel bindt, zodat provide en inject synchroon blijven zonder handmatige casts.
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()Een waarde aanbieden waarvan de vorm niet overeenkomt met ThemeContext is een compileerfout op de injectieplek en vangt zo afwijkingen tussen ver uit elkaar liggende componenten af voordat ze in productie komen.
Veelgestelde sollicitatievragen over Vue en TypeScript
Interviewers peilen of een kandidaat de grens begrijpt tussen typen tijdens het compileren en gedrag tijdens runtime. Een paar terugkerende vragen en de scherpe antwoorden:
- Waarom
defineProps<T>()verkiezen boven de runtime-objectvorm? Generieke props drukken union-typen, functiesignaturen en geneste vormen uit die runtime-declaraties niet kunnen vatten, en ze verwijderen gedupliceerde type- en runtime-definities. - Zijn gedestructureerde props reactief? Ja, in Vue 3.5+, omdat de compiler toegang herschrijft naar
props.x. Maar gedestructureerde waarden die aanwatchof een composable worden doorgegeven, worden eenmalig gelezen — gebruik een getter oftoRef. - Hoe typeer je een uitgezonden event zonder payload? Met een lege tuple:
defineEmits<{ close: [] }>(). - Wat vervangt
defineModel? Het paar bestaande uit demodelValue-prop en deupdate:modelValue-emit, verenigd in één getypeerde schrijfbare ref.
Deze vragen oefenen tegen een echte vragenbank scherpt de reflexen die sollicitatiegesprekken belonen — de interviewmodule over Vue-composables traint precies deze patronen. Ook tooling telt: draai vue-tsc in de CI zodat typefouten merges blokkeren, en leun op het TypeScript-handboek wanneer composable-generics in het spel komen. De editorervaring wordt aangedreven door Vue's officiële Volar-tooling, die deze macro's leest om fouten inline te tonen.
Conclusie
- Gebruik het generieke
defineProps<Props>()om union-typen, optionele velden en callback-props uit te drukken die runtime-declaraties niet kunnen vastleggen. - Verkies in Vue 3.5+ reactieve prop-destructurering met inline standaardwaarden, en grijp alleen naar
withDefaultswanneer een gedeeld object met standaardwaarden duidelijker is. - Typeer events met de tuple-vorm van
defineEmitszodat payloads tijdens het compileren worden gecontroleerd in zowel het kind als de ouder. - Annoteer retourtypen van composables expliciet en gebruik generics om door de aanroeper aangeleverde datatypen van begin tot eind door te geven.
- Adopteer
defineModel<T>()voor tweewegbinding om de prop-plus-emit-boilerplate terug te brengen tot één getypeerde ref. - Draai
vue-tscin continuous integration zodat typeregressies de build laten mislukken in plaats van de productie te bereiken.
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Tags
Delen
Gerelateerde artikelen

Geavanceerde Vue 3 Composables: Herbruikbare Patronen en Interviewvragen 2026
Een diepgaande gids over geavanceerde Vue 3 composable-patronen, van async data-ophaling tot formuliervalidatie en dependency injection. Inclusief veelgestelde interviewvragen over de Composition API in 2026.

Vue 3 Composition API: Volledige gids voor reactiviteit
De Vue 3 Composition API in de praktijk: ref, reactive, computed, watch en composables voor performante Vue-applicaties.

Nuxt 4 in 2026: Nieuwe Directorystructuur en Migratie vanuit Nuxt 3
Uitgebreide gids over Nuxt 4 in 2026: de nieuwe app/-directorystructuur, shallow reactivity, verbeterde TypeScript-integratie, Unhead v2 en een praktische migratiechecklist voor Nuxt 3-projecten.