Modul 4: Composition API

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:

Refs typen
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:

Props-Vergleich
<!-- ❌ 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:

Props mit Defaults
<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>
Typsichere Props in Aktion
Interaktiv
Name:
Level:
<UserCard name="Max Mustermann" :level="2" />

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:

Emits typen
<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:

composables/usePagination.ts
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:

components/GenericList.vue
<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:

Nutzung von GenericList
<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:

Vue-Typen
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:

1

Vue - Official (ehemals Volar) installieren

VS Code Extension Vue.volar – die offizielle Language-Extension für Vue.

2

TypeScript Vue Plugin aktivieren

Ermöglicht TypeScript-Unterstützung in .vue-Dateien. In Nuxt ist das bereits vorkonfiguriert.

3

Nuxt TypeScript konfigurieren

Nuxt generiert .nuxt/tsconfig.json automatisch – du brauchst nur strict: true in deiner nuxt.config.ts.

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:

Strict-Mode Patterns
// 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: falsetyped: 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

FeatureAPITipp
Refsref<T>()Nur bei null/komplexen Typen annotieren
PropsdefineProps<T>()Interface-Syntax bevorzugen
EmitsdefineEmits<T>()Payload-Typen immer definieren
Generics<script setup generic="T">Für Listen, Tabellen, Wrapper
IDEVue - Official (Volar)Vetur deinstallieren!