TypeScript mit Vue
Typsichere Vue-Komponenten und Composables schreiben – von Props bis Generics.
Warum TypeScript mit Vue?
Vue 3 wurde von Grund auf in TypeScript geschrieben und bietet erstklassige TypeScript-Unterstützung. Deine IDE kann dadurch Props validieren, Composable-Rückgabewerte autocompleten und Fehler erkennen, bevor du die App startest.
🔍 Fehler früh finden
Tippfehler in Prop-Namen, falsche Typen und fehlende Properties werden beim Schreiben erkannt.
📝 Autocomplete
Deine IDE kennt alle Properties, Events und Slot-Props und schlägt sie automatisch vor.
📖 Dokumentation
Typen sind die beste Dokumentation – sie sind immer aktuell und maschinenlesbar.
Rails-Vergleich: Sorbet/RBS vs TypeScript
Ruby hat mit Sorbet und RBS nachträglich Typsysteme bekommen – sie fühlen sich oft wie ein Fremdkörper an. TypeScript ist dagegen von Anfang an in das Vue-Ökosystem integriert. Du bekommst Typ-Inferenz fast kostenlos, ohne aufwändige sig-Annotationen oder separate .rbs-Dateien.
Refs und Reactive typen
In den meisten Fällen inferiert Vue den Typ automatisch. Bei komplexen Typen oder null-Initialwerten musst du explizit annotieren:
import { ref, reactive, computed } from 'vue'
// ✅ Inferenz reicht aus – Vue erkennt den Typ
const count = ref(0) // Ref<number>
const name = ref('Vue') // Ref<string>
const isActive = ref(true) // Ref<boolean>
// ❗ Explizit typen bei null-Initialwerten
const user = ref<User | null>(null) // Ref<User | null>
const items = ref<string[]>([]) // Ref<string[]>
// ✅ Reactive inferiert ebenfalls
const state = reactive({
count: 0, // number
name: 'Vue', // string
users: [] as User[] // Typ-Assertion für leere Arrays
})
// ✅ Computed inferiert den Rückgabetyp
const doubled = computed(() => count.value * 2) // ComputedRef<number>
// ❗ Explizit bei komplexer Logik
const activeUsers = computed<User[]>(() =>
state.users.filter(u => u.isActive)
)Wann explizit typen?
Nutze explizite Typen nur, wenn die Inferenz nicht ausreicht: bei null-Initialwerten, Union Types, oder wenn der initiale Wert den Typ nicht vollständig beschreibt (z.B. ref([]) → Ref<never[]>).
Props mit defineProps<T> typen
Vue bietet zwei Wege, Props zu definieren. Die TypeScript-Variante mit defineProps<T>() ist kürzer und typsicherer:
<!-- ❌ Runtime-Deklaration (funktioniert, aber weniger typsicher) -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
user: { type: Object as PropType<User>, required: true }
})
</script>
<!-- ✅ Type-based Deklaration (empfohlen in TypeScript) -->
<script setup lang="ts">
interface Props {
title: string
count?: number
user: User
tags?: string[]
}
const props = defineProps<Props>()
// props.title → string
// props.count → number | undefined
// props.user → User
</script> Für Standardwerte nutzt du withDefaults:
<script setup lang="ts">
interface Props {
title: string
count?: number
variant?: 'primary' | 'secondary' | 'danger'
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
variant: 'primary',
tags: () => [] // Arrays/Objekte brauchen eine Factory-Funktion
})
// props.count → number (nicht mehr undefined dank Default)
// props.variant → 'primary' | 'secondary' | 'danger'
</script>Emits typen
Genauso wie Props kannst du auch emittierte Events typsicher definieren. So weiß die IDE, welche Events eine Komponente sendet und welche Payload sie mitbringen:
<script setup lang="ts">
// Type-based Emit-Deklaration
const emit = defineEmits<{
// Event-Name: Payload-Typ
update: [value: string]
delete: [id: number]
submit: [data: { name: string; email: string }]
close: [] // Kein Payload
}>()
// Typsichere Nutzung:
emit('update', 'neuer Wert') // ✅
emit('update', 42) // ❌ Typ-Fehler: number statt string
emit('delete', 1) // ✅
emit('close') // ✅
emit('unknown') // ❌ Event existiert nicht
// In der Eltern-Komponente:
// <MyComponent
// @update="(value) => { /* value ist string */ }"
// @delete="(id) => { /* id ist number */ }"
// />
</script> Wenn du in der Eltern-Komponente @update="handler" schreibst, kennt TypeScript den Parameter-Typ automatisch. Die IDE zeigt: (value: string) => void.
Composables typen
Composables profitieren am meisten von TypeScript. Definiere klare Rückgabetypen, damit Konsumenten sofort wissen, was sie bekommen:
interface UsePaginationOptions {
initialPage?: number
pageSize?: number
}
interface UsePaginationReturn {
page: Ref<number>
pageSize: Ref<number>
offset: ComputedRef<number>
next: () => void
prev: () => void
goTo: (page: number) => void
}
export function usePagination(
options: UsePaginationOptions = {}
): UsePaginationReturn {
const { initialPage = 1, pageSize: size = 20 } = options
const page = ref(initialPage)
const pageSize = ref(size)
const offset = computed(() => (page.value - 1) * pageSize.value)
function next() { page.value++ }
function prev() { if (page.value > 1) page.value-- }
function goTo(p: number) { page.value = Math.max(1, p) }
return { page, pageSize, offset, next, prev, goTo }
}
// Nutzung:
// const { page, next, prev } = usePagination({ pageSize: 10 })Generic Composables
Nutze Generics für Composables, die mit verschiedenen Datentypen arbeiten. useFetch<User[]>(url) gibt dann Ref<User[] | null> zurück – volle Typsicherheit ohne Casts.
Generische Komponenten
Seit Vue 3.3 unterstützt <script setup> Generics direkt. Das ist perfekt für Listen, Tabellen und andere Wrapper-Komponenten:
<script setup lang="ts" generic="T extends { id: number | string }">
defineProps<{
items: T[]
selected?: T
}>()
defineEmits<{
select: [item: T]
}>()
// T ist hier vollständig typisiert!
// Die Constraint "extends { id: ... }" garantiert, dass jedes Item eine id hat.
</script>
<template>
<ul>
<li
v-for="item in items"
:key="item.id"
:class="{ 'text-vue-400': selected?.id === item.id }"
@click="$emit('select', item)"
>
<slot :item="item" />
</li>
</ul>
</template>Bei der Nutzung inferiert Vue den Typ automatisch:
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
const users = ref<User[]>([
{ id: 1, name: 'Anna', email: 'anna@example.de' },
{ id: 2, name: 'Ben', email: 'ben@example.de' },
])
const selected = ref<User>()
</script>
<template>
<!-- Vue inferiert T = User automatisch! -->
<GenericList
:items="users"
:selected="selected"
@select="(user) => { selected = user }"
>
<template #default="{ item }">
<!-- item ist hier vom Typ User ✅ -->
{{ item.name }} ({{ item.email }})
</template>
</GenericList>
</template>Typ-Inferenz bei Generics
Vue inferiert T aus den übergebenen Props. Wenn du :items="users" übergibst und users vom Typ User[] ist, weiß Vue automatisch, dass T = User ist. Der Slot-Prop item hat dann den Typ User.
Nützliche Vue-Typen
Vue exportiert viele hilfreiche TypeScript-Typen. Hier die wichtigsten:
import type {
Ref, // ref() Rückgabetyp
ComputedRef, // computed() Rückgabetyp
MaybeRef, // T | Ref<T>
MaybeRefOrGetter, // T | Ref<T> | (() => T)
PropType, // Für Runtime-Props
ComponentInstance, // Komponenten-Instanz-Typ
ExtractPropTypes, // Props-Typ aus Runtime-Definition extrahieren
} from 'vue'
// MaybeRef für flexible Composable-Parameter
function useTitle(title: MaybeRef<string>) {
const resolved = toValue(title) // Immer string
}
// ComponentInstance für Template-Refs
const formRef = ref<ComponentInstance<typeof MyForm>>()
formRef.value?.validate() // Typsicher!
// PropType für Runtime-Definition (falls nötig)
defineProps({
user: {
type: Object as PropType<{ name: string; role: 'admin' | 'user' }>,
required: true
}
})IDE-Setup: Vue Language Features
Für das beste TypeScript-Erlebnis brauchst du das richtige IDE-Setup:
Vue - Official (ehemals Volar) installieren
VS Code Extension Vue.volar – die offizielle Language-Extension für Vue.
TypeScript Vue Plugin aktivieren
Ermöglicht TypeScript-Unterstützung in .vue-Dateien. In Nuxt ist das bereits vorkonfiguriert.
Nuxt TypeScript konfigurieren
Nuxt generiert .nuxt/tsconfig.json automatisch – du brauchst nur strict: true in deiner nuxt.config.ts.
// nuxt.config.ts
export default defineNuxtConfig({
typescript: {
strict: true, // Empfohlen: Strikterer Modus
typeCheck: true, // Typ-Check beim Build (optional, langsamer)
// shim: false, // Für reine TypeScript-Projekte
}
})Vetur deinstallieren!
Wenn du noch die alte Vetur-Extension installiert hast, deinstalliere sie! Vetur und die neue Vue-Official-Extension konkurrieren miteinander und verursachen Fehler. Nutze ausschließlich Vue - Official (Volar).
Praxis-Tipps für strict Mode
Mit strict: true zeigt TypeScript mehr Fehler. Hier die häufigsten Situationen und wie du sie löst:
// Problem 1: Possibly undefined
const user = ref<User | null>(null)
console.log(user.value.name) // ❌ Object possibly null
console.log(user.value?.name) // ✅ Optional Chaining
// Problem 2: Template Refs
const inputRef = ref<HTMLInputElement | null>(null)
inputRef.value.focus() // ❌ Object possibly null
inputRef.value?.focus() // ✅ Sicher
// Problem 3: Event-Handler Typen
function onInput(event: Event) {
const value = event.target.value // ❌ Property 'value' does not exist
const value = (event.target as HTMLInputElement).value // ✅ Cast
}
// Problem 4: API-Responses
const { data } = await useFetch<User[]>('/api/users')
data.value?.forEach(user => { // ✅ data kann null sein
console.log(user.name)
})
// Problem 5: Leere Arrays typen
const items = ref([]) // ❌ Ref<never[]>
const items = ref<string[]>([]) // ✅ Ref<string[]>Rails-Vergleich: Graduelle Typisierung
Wie Sorbet's typed: false → typed: strict Migration kannst du auch TypeScript schrittweise einführen. Starte mit .ts für neue Dateien und lass bestehende .js-Dateien zunächst unverändert. TypeScript checkt nur .ts-Dateien strikt – .js bekommt eine lockerere Prüfung.
Zusammenfassung
| Feature | API | Tipp |
|---|---|---|
| Refs | ref<T>() | Nur bei null/komplexen Typen annotieren |
| Props | defineProps<T>() | Interface-Syntax bevorzugen |
| Emits | defineEmits<T>() | Payload-Typen immer definieren |
| Generics | <script setup generic="T"> | Für Listen, Tabellen, Wrapper |
| IDE | Vue - Official (Volar) | Vetur deinstallieren! |