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 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.
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.
<!-- 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:
<!-- 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.
<!-- 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>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.
<!-- 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.
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:
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.
<!-- 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.
<!-- 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.
<!-- 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.
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()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 dowatchlub composable są odczytywane raz — użyj gettera albotoRef. - Jak otypować emitowane zdarzenie bez ładunku? Pustą krotką:
defineEmits<{ close: [] }>(). - Co zastępuje
defineModel? Parę: propsmodelValueoraz emitupdate: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
withDefaultssię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-tscw 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
Udostępnij
Powiązane artykuły

Vue 3 Composition API: Kompletny przewodnik po reaktywności i kompozycji
Praktyczny przewodnik po Vue 3 Composition API. Ref, reactive, computed, watch i composables — wszystko, czego potrzeba do budowania wydajnych aplikacji Vue.

Nuxt 4 w 2026: Nowa Struktura Katalogow i Migracja z Nuxt 3
Kompletny przewodnik po Nuxt 4: nowa struktura katalogu app/, migracja krok po kroku z Nuxt 3, singleton data fetching, shallow reactivity, TypeScript context splitting, Vue Router v5, zarzadzanie meta tagami i lista kontrolna migracji.

Vue 3 Pinia vs Vuex: Nowoczesne zarządzanie stanem i pytania rekrutacyjne 2026
Porównanie Pinia i Vuex: architektura, TypeScript, Composition API, migracja, hydratacja SSR oraz najczęstsze pytania rekrutacyjne o zarządzanie stanem Vue na rok 2026.