Vue 3 z TypeScriptem w 2026: bezpieczne typowanie propsów, emitów i composables

Praktyczny przewodnik po typowaniu komponentów Vue 3 w TypeScript: generyczne defineProps, krotkowe defineEmits, typowane composables, defineModel oraz InjectionKey wraz z pytaniami rekrutacyjnymi.

Vue 3 TypeScript: bezpieczne typowanie propsów, emitów i composables

Vue 3 w połączeniu z TypeScriptem w 2026 roku zapewnia pełną, statyczną kontrolę typów obejmującą propsy, zdarzenia oraz logikę wielokrotnego użytku. Składnia <script setup> razem z makrami kompilatora, takimi jak defineProps i defineEmits, zamienia dawne definicje dostępne wyłącznie w czasie działania w kontrakty weryfikowane już podczas kompilacji. Ten przewodnik omawia wzorce bezpiecznego typowania, które przydają się zarówno w kodzie produkcyjnym, jak i podczas rozmów rekrutacyjnych.

Bezpieczne propsy w jednej linii

W Vue 3.5+ generyczne defineProps<T>() wywodzi typy propsów bezpośrednio z interfejsu TypeScript — bez pól type w czasie działania i bez rzutowania przez PropType. W połączeniu z reaktywną destrukturyzacją propsów wartości domyślne definiuje się w tej samej instrukcji: const { size = 'md' } = defineProps<Props>().

Typowanie propsów za pomocą defineProps i interfejsów TypeScript

Deklaracja propsów w czasie działania (props: { title: String }) działa poprawnie, ale powiela informacje o typach i traci precyzję. Typów unijnych, zagnieżdżonych obiektów oraz sygnatur funkcji nie da się wyrazić w czysty sposób. Generyczna forma defineProps rozwiązuje ten problem, przyjmując kształt z typu TypeScript, który kompilator Vue automatycznie zamienia na właściwą deklarację czasu działania.

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>

Props role jest teraz ograniczony do dwóch literałów tekstowych, więc przekazanie role="guest" powoduje błąd kompilacji. To główny powód, by sięgać po defineProps z TypeScriptem: oficjalny przewodnik Vue po TypeScript traktuje formę generyczną jako domyślną w projektach opartych na <script setup>.

Wartości domyślne propsów: withDefaults kontra reaktywna destrukturyzacja

Propsy oparte wyłącznie na typach nie mają własnego mechanizmu wartości domyślnych w czasie działania. Historycznie odpowiedzią było withDefaults, które opakowuje makro i scala obiekt wartości domyślnych:

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 ustabilizowało reaktywną destrukturyzację propsów, która jest dziś zwięźlejszym wzorcem. Destrukturyzacja wyniku defineProps i przypisanie wartości domyślnej w tej samej instrukcji pozostaje w pełni reaktywne — kompilator pod spodem przepisuje każdy odczyt z powrotem na props.x.

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>
Pułapka reaktywności przy destrukturyzacji propsów

Zdestrukturyzowane propsy pozostają reaktywne w szablonie oraz w computed, ale przekazanie zdestrukturyzowanej wartości bezpośrednio do watch lub do composable odczytuje ją tylko raz i zrywa powiązanie reaktywne. Opakowuj taką wartość w getter — watch(() => color, ...) — albo konwertuj przez toRef(props, 'color'), gdy dalej potrzebny jest ref.

To częsta pułapka rekrutacyjna: kandydaci zakładają, że zdestrukturyzowana zmienna jest zwykłą wartością, podczas gdy kompilator w rzeczywistości przekierował każdy odczyt z powrotem na props.color.

Bezpieczne emity dzięki defineEmits

Zdarzenia zasługują na tę samą staranność co propsy. Generyczne defineEmits opisuje każdą nazwę zdarzenia oraz jego ładunek w postaci krotki, dając komponentowi rodzica podpowiedzi kodu, a komponentowi dziecka kontrolę już w czasie kompilacji, że emitowane są właściwe argumenty.

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>

Ta forma krotkowa zastąpiła starszą składnię sygnatur wywołania ((e: 'search', q: string): void), ponieważ czyta się ją lepiej i obsługuje wiele zdarzeń bez przeciążeń. Gdy zdarzenie nie niesie danych, pusta krotka [] dokumentuje to wprost. Połączenie typowanych emitów z typowanymi propsami daje komponenty, których cały publiczny interfejs jest weryfikowalny przed uruchomieniem — ta sama dyscyplina, którą opisuje przewodnik po Composition API Vue.

Typowanie composables dla logiki wielokrotnego użytku

Composables to zwykłe funkcje, więc podlegają standardowym regułom TypeScript — ale kilka konwencji utrzymuje je ergonomicznymi. Zwracaj typy Ref jawnie, gdy wnioskowanie nie jest oczywiste, i sięgaj po typy generyczne, gdy composable opakowuje dowolne dane, takie jak odpowiedź API.

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

W miejscu wywołania parametr generyczny sprawia, że data jest w pełni otypowane bez dodatkowych adnotacji:

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

Jawne otypowanie zwracanego obiektu (UseFetchReturn<T>) warte jest tych kilku dodatkowych linii: dokumentuje kontrakt, zapobiega przypadkowemu wyciekowi wewnętrznych refów i daje konsumentom jeden typ do zaimportowania. Głębsze wzorce composables, takie jak przeciążenia argumentów i sprzątanie powiązane z cyklem życia, omawia zaawansowany przewodnik po composables Vue.

Gotowy na rozmowy o Vue.js / Nuxt.js?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Typowanie defineModel dla dwukierunkowego wiązania

defineModel, stabilne od Vue 3.4, łączy dawny props modelValue oraz emit update:modelValue w jeden zapisywalny ref. Jego parametr generyczny typuje obie strony wiązania naraz.

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>

Modele nazwane — defineModel<string>('title') — mapują się na v-model:title i otrzymują to samo typowanie. Eliminuje to całą klasę błędów niezgodnego ładunku, które ręczny wzorzec props-plus-emit potrafił ukrywać.

Typowanie refów szablonu i instancji komponentów

Dostęp do węzła DOM lub komponentu potomnego przez ref to miejsce, w którym nieotypowany kod Vue najczęściej wraca do any. Rozwiązaniem jest sparametryzowanie useTemplateRef (Vue 3.5+) lub samego ref typem elementu, dzięki czemu dostęp do właściwości jest sprawdzany względem rzeczywistego interfejsu DOM.

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>

Dla referencji do komponentu potomnego InstanceType<typeof Child> wydobywa publiczny typ komponentu, udostępniając wszystko, co dziecko zadeklarowało przez defineExpose. Dzięki temu wywołania metod z rodzica do dziecka są w pełni sprawdzane, a nie zgadywane.

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>

Bezpieczne provide i inject z InjectionKey

Wstrzykiwanie zależności w drzewie komponentów traci informacje o typach, o ile klucz ich nie przenosi. InjectionKey<T> to otypowany symbol, który wiąże typ wartości z jej kluczem, dzięki czemu provide i inject pozostają zgodne bez ręcznego rzutowania.

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

Przekazanie wartości, której kształt nie pasuje do ThemeContext, jest błędem kompilacji w miejscu wstrzyknięcia, co wychwytuje rozjazd między odległymi komponentami, zanim trafi na produkcję.

Typowe pytania rekrutacyjne o Vue i TypeScript

Rekruterzy sprawdzają, czy kandydat rozumie granicę między typami w czasie kompilacji a zachowaniem w czasie działania. Kilka powracających pytań i ich trafne odpowiedzi:

  • Dlaczego preferować defineProps<T>() zamiast formy obiektowej czasu działania? Generyczne propsy wyrażają typy unijne, sygnatury funkcji i zagnieżdżone kształty, których deklaracje czasu działania nie potrafią oddać, a przy tym usuwają zdublowane definicje typów i wartości runtime.
  • Czy zdestrukturyzowane propsy są reaktywne? Tak, w Vue 3.5+, ponieważ kompilator przepisuje dostęp na props.x. Ale zdestrukturyzowane wartości przekazane do watch lub composable są odczytywane raz — użyj gettera albo toRef.
  • Jak otypować emitowane zdarzenie bez ładunku? Pustą krotką: defineEmits<{ close: [] }>().
  • Co zastępuje defineModel? Parę: props modelValue oraz emit update:modelValue, zjednoczoną w jeden otypowany, zapisywalny ref.

Ćwiczenie tych zagadnień na prawdziwej bazie pytań wyostrza odruchy, które doceniają rozmowy rekrutacyjne — moduł pytań rekrutacyjnych o composables Vue trenuje dokładnie te wzorce. Narzędzia też mają znaczenie: uruchamiaj vue-tsc w CI, aby błędy typów blokowały scalanie, i sięgaj po podręcznik TypeScript, gdy w grę wchodzą generyczne composables. Doświadczenie w edytorze napędza oficjalne narzędzie Volar od Vue, które odczytuje te makra i pokazuje błędy w miejscu ich powstania.

Podsumowanie

  • Używaj generycznego defineProps<Props>(), aby wyrazić typy unijne, pola opcjonalne i propsy będące callbackami, których deklaracje czasu działania nie potrafią uchwycić.
  • Preferuj reaktywną destrukturyzację propsów z wartościami domyślnymi w linii w Vue 3.5+, a po withDefaults sięgaj tylko wtedy, gdy wspólny obiekt wartości domyślnych jest czytelniejszy.
  • Typuj zdarzenia formą krotkową defineEmits, aby ładunki były sprawdzane w czasie kompilacji zarówno w dziecku, jak i w rodzicu.
  • Adnotuj typy zwracane composables jawnie i używaj typów generycznych, by przekazywać typy danych dostarczone przez wywołującego od początku do końca.
  • Wybieraj defineModel<T>() dla wiązania dwukierunkowego, aby zwinąć szablonowy kod props-plus-emit w jeden otypowany ref.
  • Uruchamiaj vue-tsc w ciągłej integracji, aby regresje typów zatrzymywały build, zamiast trafiać na produkcję.

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Tagi

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Udostępnij

Powiązane artykuły