Vue 3 mit TypeScript im Jahr 2026: Typsichere Props, Emits und Composables

Typsichere Vue-3-Komponenten mit TypeScript meistern: generisches defineProps, defineEmits als Tuple, typisierte Composables, defineModel und InjectionKey samt Interviewfragen.

Vue 3 TypeScript: typsichere Props, Emits und Composables

Vue 3 mit TypeScript liefert im Jahr 2026 eine vollständig statische Typprüfung über Props, Events und wiederverwendbare Logik hinweg. Die Syntax <script setup> in Kombination mit Compiler-Makros wie defineProps und defineEmits verwandelt frühere Objektdefinitionen, die nur zur Laufzeit galten, in Verträge zur Kompilierzeit. Dieser Leitfaden behandelt die typsicheren Muster, die jede Vue-Entwicklerin und jeder Vue-Entwickler für produktiven Code und technische Interviews benötigt.

Typsichere Props in einer Zeile

In Vue 3.5+ leitet das generische defineProps<T>() die Prop-Typen direkt aus einem TypeScript-Interface ab — keine type-Felder zur Laufzeit, keine PropType-Casts. In Verbindung mit reaktivem Prop-Destructuring stehen Standardwerte inline: const { size = 'md' } = defineProps<Props>().

Props mit defineProps und TypeScript-Interfaces typisieren

Die Laufzeit-Deklaration von Props (props: { title: String }) funktioniert, dupliziert jedoch Typinformationen und verliert an Präzision. Union-Typen, verschachtelte Objekte und Funktionssignaturen lassen sich damit nicht sauber ausdrücken. Die generische Form von defineProps löst dieses Problem, indem sie die Struktur aus einem TypeScript-Typ übernimmt, den der Vue-Compiler anschließend automatisch in die korrekte Laufzeit-Deklaration übersetzt.

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>

Die Prop role ist nun auf zwei literale Zeichenketten beschränkt, sodass das Übergeben von role="guest" den Build scheitern lässt. Genau das ist der Kerngrund, zu defineProps mit TypeScript zu greifen: Der offizielle Vue-TypeScript-Leitfaden behandelt die generische Form als Standard für Projekte mit <script setup>.

Standardwerte für Props: withDefaults vs. reaktives Destructuring

Reine Typ-Props verfügen für sich genommen über keinen Laufzeit-Mechanismus für Standardwerte. Historisch lautete die Antwort withDefaults, das das Makro umschließt und ein Objekt mit Standardwerten zusammenführt:

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 hat das reaktive Prop-Destructuring stabilisiert, das inzwischen das prägnantere Muster darstellt. Wird das Ergebnis von defineProps destrukturiert und im selben Ausdruck ein Standardwert zugewiesen, bleibt der Zugriff vollständig reaktiv — der Compiler schreibt jeden Zugriff im Hintergrund wieder auf props.x um.

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>
Reaktivitätsfalle beim Destructuring von Props

Destrukturierte Props bleiben im Template und in computed reaktiv, aber das direkte Übergeben eines destrukturierten Werts an watch oder ein Composable liest ihn nur einmal und trennt die reaktive Verbindung. Für solche Fälle empfiehlt sich ein Getter — watch(() => color, ...) — oder die Umwandlung mit toRef(props, 'color'), wenn nachgelagert eine Ref benötigt wird.

Dies ist eine häufige Interview-Falle: Kandidatinnen und Kandidaten nehmen an, die destrukturierte Variable sei ein einfacher Wert, während der Compiler in Wahrheit jeden Lesezugriff wieder auf props.color umgeleitet hat.

Typsichere Events mit defineEmits

Events verdienen dieselbe Sorgfalt wie Props. Das generische defineEmits beschreibt jeden Event-Namen und dessen Nutzlast als Tuple und liefert der Elternkomponente Autovervollständigung sowie der Kindkomponente Prüfungen zur Kompilierzeit, dass die richtigen Argumente ausgelöst werden.

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>

Diese Tuple-Form ersetzte die ältere Aufrufsignatur-Syntax ((e: 'search', q: string): void), weil sie besser lesbar ist und mehrere Events ohne Überladungen unterstützt. Wenn ein Event keine Daten transportiert, dokumentiert ein leeres Tuple [] dies ausdrücklich. Die Kombination typisierter Events mit typisierten Props erzeugt Komponenten, deren gesamte öffentliche Schnittstelle vor der Laufzeit überprüfbar ist — dieselbe Disziplin, die auch im Leitfaden zur Vue Composition API behandelt wird.

Composables für wiederverwendbare Logik typisieren

Composables sind gewöhnliche Funktionen und folgen daher den normalen TypeScript-Regeln — einige Konventionen halten sie jedoch ergonomisch. Es empfiehlt sich, Ref-Typen explizit zurückzugeben, wenn die Inferenz nicht offensichtlich ist, und Generics zu verwenden, wenn ein Composable beliebige Daten wie eine API-Antwort kapselt.

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 }
}

An der Aufrufstelle macht der Generics-Parameter data vollständig typisiert, ganz ohne zusätzliche Annotation:

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

Die explizite Annotation des Rückgabeobjekts (UseFetchReturn<T>) ist die wenigen zusätzlichen Zeilen wert: Sie dokumentiert den Vertrag, verhindert das versehentliche Leaken interner Refs und liefert Konsumenten einen einzigen Typ zum Importieren. Für tiefergehende Composable-Muster wie Argumentüberladungen und lebenszyklusbewusste Bereinigung siehe den Leitfaden zu fortgeschrittenen Vue-Composables.

Bereit für deine Vue.js / Nuxt.js-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

defineModel für bidirektionale Bindung typisieren

defineModel, stabil seit Vue 3.4, fasst die alte Kombination aus modelValue-Prop und update:modelValue-Emit in einer einzigen beschreibbaren Ref zusammen. Der Generics-Parameter typisiert beide Richtungen der Bindung auf einmal.

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>

Benannte Modelle — defineModel<string>('title') — werden auf v-model:title abgebildet und erhalten dieselbe Typisierung. Damit entfällt eine ganze Klasse von Fehlern durch nicht zueinander passende Nutzlasten, die das manuelle Muster aus Prop und Emit früher verbarg.

Template-Refs und Komponenteninstanzen typisieren

Der Zugriff auf einen DOM-Knoten oder eine Kindkomponente über eine ref ist die Stelle, an der untypisierter Vue-Code am häufigsten auf any zurückfällt. Die Lösung besteht darin, useTemplateRef (Vue 3.5+) oder die ref selbst mit dem Elementtyp zu parametrisieren, sodass der Eigenschaftszugriff gegen die echte DOM-Schnittstelle geprüft wird.

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>

Für eine Referenz auf eine Kindkomponente extrahiert InstanceType<typeof Child> den öffentlichen Typ der Komponente und legt offen, was das Kind über defineExpose bereitstellt. So bleiben Methodenaufrufe von der Eltern- zur Kindkomponente vollständig geprüft statt geraten.

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>

Typsicheres provide und inject mit InjectionKey

Dependency Injection über den Komponentenbaum hinweg verliert Typinformationen, sofern der Schlüssel sie nicht mitführt. InjectionKey<T> ist ein typisiertes Symbol, das den Typ eines Werts an seinen Schlüssel bindet, sodass provide und inject ohne manuelle Casts synchron bleiben.

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

Das Bereitstellen eines Werts, dessen Struktur nicht zu ThemeContext passt, ist ein Kompilierfehler an der Injektionsstelle und fängt so Abweichungen zwischen weit entfernten Komponenten ab, bevor sie ausgeliefert werden.

Häufige Interviewfragen zu Vue und TypeScript

Interviewende prüfen, ob eine Kandidatin oder ein Kandidat die Grenze zwischen Typen zur Kompilierzeit und Verhalten zur Laufzeit versteht. Einige wiederkehrende Fragen und ihre prägnanten Antworten:

  • Warum ist defineProps<T>() der Laufzeit-Objektform vorzuziehen? Generische Props drücken Union-Typen, Funktionssignaturen und verschachtelte Strukturen aus, die Laufzeit-Deklarationen nicht abbilden können, und sie beseitigen doppelte Typ- und Laufzeitdefinitionen.
  • Sind destrukturierte Props reaktiv? Ja, in Vue 3.5+, weil der Compiler den Zugriff auf props.x umschreibt. Doch destrukturierte Werte, die an watch oder ein Composable übergeben werden, werden nur einmal gelesen — hier hilft ein Getter oder toRef.
  • Wie typisiert man ein ausgelöstes Event ohne Nutzlast? Mit einem leeren Tuple: defineEmits<{ close: [] }>().
  • Was ersetzt defineModel? Das Paar aus modelValue-Prop und update:modelValue-Emit, vereinigt in einer einzigen typisierten, beschreibbaren Ref.

Diese Fragen an einer echten Fragensammlung zu üben schärft die Reflexe, die Interviews belohnen — das Interview-Modul zu Vue-Composables trainiert genau diese Muster. Auch das Tooling zählt: vue-tsc in der CI ausführen, damit Typfehler Merges blockieren, und bei Composable-Generics auf das TypeScript-Handbuch zurückgreifen. Das Editor-Erlebnis wird von Vues offiziellem Volar-Tooling angetrieben, das diese Makros liest, um Fehler inline anzuzeigen.

Fazit

  • Generisches defineProps<Props>() verwenden, um Union-Typen, optionale Felder und Callback-Props auszudrücken, die Laufzeit-Deklarationen nicht erfassen können.
  • In Vue 3.5+ reaktives Prop-Destructuring mit Inline-Standardwerten bevorzugen und withDefaults nur dann heranziehen, wenn ein gemeinsam genutztes Objekt mit Standardwerten übersichtlicher ist.
  • Events mit der Tuple-Form von defineEmits typisieren, damit Nutzlasten sowohl im Kind als auch in der Elternkomponente zur Kompilierzeit geprüft werden.
  • Rückgabetypen von Composables explizit annotieren und Generics nutzen, um vom Aufrufer gelieferte Datentypen durchgängig weiterzureichen.
  • defineModel<T>() für bidirektionale Bindung einsetzen, um den Boilerplate aus Prop und Emit auf eine einzige typisierte Ref zu reduzieren.
  • vue-tsc in der kontinuierlichen Integration ausführen, damit Typregressionen den Build scheitern lassen, statt in die Produktion zu gelangen.

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Teilen

Verwandte Artikel