Vue 3 з TypeScript у 2026: типобезпечні props, emits та composables
Практичний посібник із типізації компонентів Vue 3 у TypeScript: узагальнені defineProps, кортежні defineEmits, типізовані composables, defineModel та InjectionKey разом із питаннями для співбесід.

Vue 3 у поєднанні з TypeScript у 2026 році забезпечує повну статичну перевірку типів для props, подій та повторно використовуваної логіки. Синтаксис <script setup> разом із макросами компілятора, такими як defineProps і defineEmits, перетворює колишні визначення об’єктів, що діяли лише під час виконання, на контракти, які перевіряються ще на етапі компіляції. Цей посібник охоплює типобезпечні патерни, потрібні кожному розробнику Vue як у продакшн-коді, так і на технічних співбесідах.
У Vue 3.5+ узагальнений defineProps<T>() виводить типи props безпосередньо з інтерфейсу TypeScript — без полів type під час виконання й без приведення через PropType. У поєднанні з реактивною деструктуризацією props значення за замовчуванням задаються в тому самому рядку: const { size = 'md' } = defineProps<Props>().
Типізація props за допомогою defineProps та інтерфейсів TypeScript
Оголошення props під час виконання (props: { title: String }) працює, але дублює інформацію про типи й втрачає точність. Об’єднані типи, вкладені об’єкти та сигнатури функцій неможливо виразити чисто. Узагальнена форма defineProps вирішує це, беручи форму з типу TypeScript, який компілятор 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 тепер обмежений двома рядковими літералами, тож передавання role="guest" спричиняє помилку збірки. Це головна причина застосовувати defineProps з TypeScript: офіційний посібник Vue з TypeScript вважає узагальнену форму типовою для проєктів на <script setup>.
Значення props за замовчуванням: withDefaults проти реактивної деструктуризації
Props, засновані лише на типах, самі по собі не мають механізму значень за замовчуванням під час виконання. Історично відповіддю було withDefaults, яке огортає макрос і зливає об’єкт значень за замовчуванням:
<!-- 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 стабілізувало реактивну деструктуризацію props, яка сьогодні є лаконічнішим патерном. Деструктуризація результату defineProps і присвоєння значення за замовчуванням у тій самій інструкції залишається повністю реактивною — компілятор під капотом переписує кожне звернення назад на 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>Деструктуризовані props залишаються реактивними в шаблоні та в computed, але передавання деструктуризованого значення безпосередньо у watch чи у composable зчитує його лише раз і розриває реактивний зв’язок. Огортайте таке значення в геттер — watch(() => color, ...) — або перетворюйте через toRef(props, 'color'), коли далі потрібен ref.
Це поширена пастка на співбесідах: кандидати вважають деструктуризовану змінну звичайним значенням, тоді як компілятор насправді перенаправив кожне зчитування назад на props.color.
Типобезпечні emits за допомогою defineEmits
Події заслуговують на ту саму ретельність, що й props. Узагальнений defineEmits описує кожну назву події та її корисне навантаження у вигляді кортежу, надаючи батьківському компоненту автодоповнення, а дочірньому — перевірку на етапі компіляції, що передаються правильні аргументи.
<!-- 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>Ця кортежна форма замінила старіший синтаксис сигнатур виклику ((e: 'search', q: string): void), бо читається краще й підтримує кілька подій без перевантажень. Коли подія не несе даних, порожній кортеж [] документує це явно. Поєднання типізованих emits із типізованими props дає компоненти, весь публічний інтерфейс яких перевіряється до виконання, — та сама дисципліна, що описана в посібнику з Composition API Vue.
Типізація composables для повторно використовуваної логіки
Composables — це звичайні функції, тож вони підпорядковуються стандартним правилам TypeScript, але кілька домовленостей роблять їх ергономічними. Повертайте типи Ref явно, коли виведення неочевидне, і використовуйте узагальнення, коли composable огортає довільні дані, наприклад відповідь 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 }
}У місці виклику узагальнений параметр робить data повністю типізованим без жодної додаткової анотації:
interface User { id: number; name: string }
// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')Явна анотація об’єкта, що повертається (UseFetchReturn<T>), варта цих кількох додаткових рядків: вона документує контракт, запобігає випадковому витоку внутрішніх ref і дає споживачам єдиний тип для імпорту. Про глибші патерни composables, як-от перевантаження аргументів і очищення, прив’язане до життєвого циклу, читайте в розширеному посібнику з composables Vue.
Готовий до співбесід з Vue.js / Nuxt.js?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Типізація defineModel для двостороннього зв’язування
defineModel, стабільний із Vue 3.4, згортає колишній props modelValue та emit update:modelValue в єдиний записуваний ref. Його узагальнений параметр типізує обидва напрямки зв’язування одразу.
<!-- 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>Іменовані моделі — defineModel<string>('title') — відображаються на v-model:title й отримують ту саму типізацію. Це усуває цілий клас помилок невідповідного навантаження, які ручний патерн props-плюс-emit зазвичай приховував.
Типізація ref шаблонів та екземплярів компонентів
Доступ до вузла DOM чи дочірнього компонента через ref — це місце, де нетипізований код Vue найчастіше повертається до any. Рішення — параметризувати useTemplateRef (Vue 3.5+) або сам ref типом елемента, щоб доступ до властивостей перевірявся щодо справжнього інтерфейсу 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>Для посилання на дочірній компонент InstanceType<typeof Child> витягує публічний тип компонента, відкриваючи все, що дочірній компонент оголосив через defineExpose. Це тримає виклики методів від батька до дитини повністю перевіреними, а не вгаданими.
<!-- 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>Типобезпечні provide та inject з InjectionKey
Впровадження залежностей у дереві компонентів втрачає інформацію про типи, якщо ключ її не переносить. InjectionKey<T> — це типізований символ, що прив’язує тип значення до його ключа, тож provide та inject залишаються узгодженими без ручного приведення.
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()Надання значення, форма якого не відповідає ThemeContext, є помилкою компіляції в місці впровадження, що вловлює розбіжність між віддаленими компонентами ще до потрапляння в продакшн.
Типові питання для співбесід із Vue та TypeScript
Співрозмовники перевіряють, чи розуміє кандидат межу між типами часу компіляції та поведінкою під час виконання. Кілька повторюваних питань і чіткі відповіді на них:
- Чому варто надавати перевагу
defineProps<T>()перед об’єктною формою часу виконання? Узагальнені props виражають об’єднані типи, сигнатури функцій та вкладені форми, яких оголошення часу виконання не можуть відтворити, і водночас усувають дубльовані визначення типів і часу виконання. - Чи є деструктуризовані props реактивними? Так, у Vue 3.5+, бо компілятор переписує доступ на
props.x. Але деструктуризовані значення, передані уwatchчи composable, зчитуються один раз — використовуйте геттер абоtoRef. - Як типізувати подію без корисного навантаження? Порожнім кортежем:
defineEmits<{ close: [] }>(). - Що замінює
defineModel? Пару: propsmodelValueта emitupdate:modelValue, об’єднану в один типізований записуваний ref.
Відпрацювання цих тем на справжній базі питань загострює рефлекси, які цінують співбесіди, — модуль питань для співбесід із composables Vue тренує саме ці патерни. Інструменти теж важливі: запускайте vue-tsc у CI, щоб помилки типів блокували злиття, і звертайтеся до довідника TypeScript, коли до справи долучаються узагальнення composables. Досвід у редакторі забезпечує офіційний інструментарій Volar від Vue, який читає ці макроси й показує помилки прямо в коді.
Висновок
- Використовуйте узагальнений
defineProps<Props>(), щоб виразити об’єднані типи, необов’язкові поля та props-колбеки, яких оголошення часу виконання не здатні охопити. - Надавайте перевагу реактивній деструктуризації props із вбудованими значеннями за замовчуванням у Vue 3.5+, а до
withDefaultsвдавайтеся лише тоді, коли спільний об’єкт значень за замовчуванням зрозуміліший. - Типізуйте події кортежною формою
defineEmits, щоб навантаження перевірялися на етапі компіляції як у дитині, так і в батькові. - Явно анотуйте типи, що повертають composables, і використовуйте узагальнення, щоб наскрізно передавати надані викликачем типи даних.
- Застосовуйте
defineModel<T>()для двостороннього зв’язування, щоб згорнути шаблонний код props-плюс-emit в один типізований ref. - Запускайте
vue-tscу безперервній інтеграції, щоб регресії типів зупиняли збірку, а не потрапляли в продакшн.
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

Vue 3 Composition API: Повний посібник з реактивності та композиції
Практичний посібник з Vue 3 Composition API. ref, reactive, computed, watch і composables — усе необхідне для створення продуктивних Vue-застосунків.

Nuxt 4 у 2026 році: Нова структура каталогів та міграція з Nuxt 3
Повний посібник з міграції на Nuxt 4: нова структура app/, singleton-шар отримання даних, поверхнева реактивність за замовчуванням, розділення TypeScript-контексту, нормалізовані імена компонентів, Vue Router v5, зміни в управлінні head та контрольний список міграції.

Vue 3 Pinia vs Vuex: Сучасне управління станом та питання для співбесід 2026
Порівняння Pinia та Vuex: архітектура, TypeScript, Composition API, міграція, гідратація SSR та найпоширеніші питання зі співбесід щодо управління станом Vue у 2026 році.