Vue 3 з TypeScript у 2026: типобезпечні props, emits та composables

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

Vue 3 TypeScript: типобезпечні props, emits та composables

Vue 3 у поєднанні з TypeScript у 2026 році забезпечує повну статичну перевірку типів для props, подій та повторно використовуваної логіки. Синтаксис <script setup> разом із макросами компілятора, такими як defineProps і defineEmits, перетворює колишні визначення об’єктів, що діяли лише під час виконання, на контракти, які перевіряються ще на етапі компіляції. Цей посібник охоплює типобезпечні патерни, потрібні кожному розробнику Vue як у продакшн-коді, так і на технічних співбесідах.

Типобезпечні props в один рядок

У 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 автоматично перетворює на правильне оголошення часу виконання.

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, яке огортає макрос і зливає об’єкт значень за замовчуванням:

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 стабілізувало реактивну деструктуризацію props, яка сьогодні є лаконічнішим патерном. Деструктуризація результату defineProps і присвоєння значення за замовчуванням у тій самій інструкції залишається повністю реактивною — компілятор під капотом переписує кожне звернення назад на 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>
Пастка реактивності при деструктуризації props

Деструктуризовані props залишаються реактивними в шаблоні та в computed, але передавання деструктуризованого значення безпосередньо у watch чи у composable зчитує його лише раз і розриває реактивний зв’язок. Огортайте таке значення в геттер — watch(() => color, ...) — або перетворюйте через toRef(props, 'color'), коли далі потрібен ref.

Це поширена пастка на співбесідах: кандидати вважають деструктуризовану змінну звичайним значенням, тоді як компілятор насправді перенаправив кожне зчитування назад на props.color.

Типобезпечні emits за допомогою defineEmits

Події заслуговують на ту саму ретельність, що й props. Узагальнений defineEmits описує кожну назву події та її корисне навантаження у вигляді кортежу, надаючи батьківському компоненту автодоповнення, а дочірньому — перевірку на етапі компіляції, що передаються правильні аргументи.

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>

Ця кортежна форма замінила старіший синтаксис сигнатур виклику ((e: 'search', q: string): void), бо читається краще й підтримує кілька подій без перевантажень. Коли подія не несе даних, порожній кортеж [] документує це явно. Поєднання типізованих emits із типізованими props дає компоненти, весь публічний інтерфейс яких перевіряється до виконання, — та сама дисципліна, що описана в посібнику з Composition API Vue.

Типізація composables для повторно використовуваної логіки

Composables — це звичайні функції, тож вони підпорядковуються стандартним правилам TypeScript, але кілька домовленостей роблять їх ергономічними. Повертайте типи Ref явно, коли виведення неочевидне, і використовуйте узагальнення, коли composable огортає довільні дані, наприклад відповідь 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 }
}

У місці виклику узагальнений параметр робить data повністю типізованим без жодної додаткової анотації:

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

Явна анотація об’єкта, що повертається (UseFetchReturn<T>), варта цих кількох додаткових рядків: вона документує контракт, запобігає випадковому витоку внутрішніх ref і дає споживачам єдиний тип для імпорту. Про глибші патерни composables, як-от перевантаження аргументів і очищення, прив’язане до життєвого циклу, читайте в розширеному посібнику з composables Vue.

Готовий до співбесід з Vue.js / Nuxt.js?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Типізація defineModel для двостороннього зв’язування

defineModel, стабільний із Vue 3.4, згортає колишній props modelValue та emit update:modelValue в єдиний записуваний ref. Його узагальнений параметр типізує обидва напрямки зв’язування одразу.

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>

Іменовані моделі — defineModel<string>('title') — відображаються на v-model:title й отримують ту саму типізацію. Це усуває цілий клас помилок невідповідного навантаження, які ручний патерн props-плюс-emit зазвичай приховував.

Типізація ref шаблонів та екземплярів компонентів

Доступ до вузла DOM чи дочірнього компонента через ref — це місце, де нетипізований код Vue найчастіше повертається до any. Рішення — параметризувати useTemplateRef (Vue 3.5+) або сам ref типом елемента, щоб доступ до властивостей перевірявся щодо справжнього інтерфейсу 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>

Для посилання на дочірній компонент InstanceType<typeof Child> витягує публічний тип компонента, відкриваючи все, що дочірній компонент оголосив через defineExpose. Це тримає виклики методів від батька до дитини повністю перевіреними, а не вгаданими.

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>

Типобезпечні provide та inject з InjectionKey

Впровадження залежностей у дереві компонентів втрачає інформацію про типи, якщо ключ її не переносить. InjectionKey<T> — це типізований символ, що прив’язує тип значення до його ключа, тож provide та inject залишаються узгодженими без ручного приведення.

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

Надання значення, форма якого не відповідає ThemeContext, є помилкою компіляції в місці впровадження, що вловлює розбіжність між віддаленими компонентами ще до потрапляння в продакшн.

Типові питання для співбесід із Vue та TypeScript

Співрозмовники перевіряють, чи розуміє кандидат межу між типами часу компіляції та поведінкою під час виконання. Кілька повторюваних питань і чіткі відповіді на них:

  • Чому варто надавати перевагу defineProps<T>() перед об’єктною формою часу виконання? Узагальнені props виражають об’єднані типи, сигнатури функцій та вкладені форми, яких оголошення часу виконання не можуть відтворити, і водночас усувають дубльовані визначення типів і часу виконання.
  • Чи є деструктуризовані props реактивними? Так, у Vue 3.5+, бо компілятор переписує доступ на props.x. Але деструктуризовані значення, передані у watch чи composable, зчитуються один раз — використовуйте геттер або toRef.
  • Як типізувати подію без корисного навантаження? Порожнім кортежем: defineEmits<{ close: [] }>().
  • Що замінює defineModel? Пару: props modelValue та emit update: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
#TypeScript
#defineProps
#Composables
#Frontend

Поділитися

Пов'язані статті