Vue с TypeScript

Добавляем к компонентам Vue статические типы: меньше ошибок «undefined is not a function», автодополнение и понятные контракты между компонентами.

Vue + TypeScript даёт компилятору знание о форме ваших props, событий и состояния, поэтому опечатки и неверные типы ловятся в редакторе ещё до запуска, а не у пользователя в проде.

Чистый JS прощает многое: передали в props число вместо строки — узнаете в рантайме. TypeScript превращает молчаливые ошибки в подсказки редактора. Во Vue 3 с <script setup> типизация на удивление удобна: типы props и событий выводятся из обычных TS-конструкций, без громоздких аннотаций. Этот урок — практический минимум, который покрывает 90% повседневной работы.

Зачем типизировать компоненты

Компонент — это контракт: «дай мне такие props, а я пошлю такие события». Без типов этот контракт держится на честном слове и комментариях. С типами он проверяется: забыли обязательный prop — ошибка компиляции; передали в событие не тот payload — ошибка; обратились к несуществующему полю — редактор подчёркивает сразу. На больших проектах с десятками компонентов это экономит часы отладки.

Типизация props через defineProps

В <script setup> с TypeScript props описываются дженериком — вы передаёте интерфейс прямо в defineProps, и Vue выводит из него и типы, и проверки.

<script setup lang="ts">
interface Props {
  title: string
  count?: number          // необязательный
  tags: string[]
}

// Значения по умолчанию для необязательных props
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => []
})

console.log(props.title.toUpperCase())  // редактор знает: это string
</script>

Обратите внимание на две вещи. Первое — lang="ts" в теге <script setup> обязательно. Второе — значения по умолчанию задаются через withDefaults, а для массивов и объектов дефолт оборачивается в функцию (() => []), как и в обычном Vue, чтобы каждый экземпляр получал свой объект.

Типизация событий через defineEmits

События тоже описываются типом: вы перечисляете имена событий и форму их аргументов. Тогда вызов emit с неверным именем или payload — ошибка компиляции, а родитель в шаблоне получает типизированный $event.

<script setup lang="ts">
// Современный синтаксис: имя события -> кортеж аргументов
const emit = defineEmits<{
  submit: [payload: { email: string }]
  cancel: []
}>()

function onSend() {
  emit("submit", { email: "[email protected]" })  // ок
  // emit("submit", { name: "x" })  // ошибка: нет поля email
  // emit("sumbit", ...)            // ошибка: опечатка в имени
}
</script>

Это устраняет целый класс багов «родитель и ребёнок разошлись в контракте событий»: переименовали событие в ребёнке — TypeScript подсветит все места, где его слушают неправильно.

Типизация ref и reactive

Чаще всего тип ref выводится автоматически из начального значения: ref(0) — это Ref<number>, ref('')Ref<string>. Указывать тип вручную нужно в двух случаях: когда начальное значение null, но позже там будет объект, и когда тип шире начального значения.

<script setup lang="ts">
import { ref, reactive } from "vue"

interface User { id: number; name: string }

const count = ref(0)                 // выведен Ref<number>
const user = ref<User | null>(null)   // явно: сейчас null, потом User

// reactive обычно выводит тип сам из формы объекта
const form = reactive({
  email: "",
  agree: false
})

count.value++                        // .value — это number
user.value = { id: 1, name: "Аня" }  // тип совпадает — ок
</script>

Тонкость: внутри <script> у ref вы обращаетесь к значению через .value, и TypeScript следит за его типом. В шаблоне .value разворачивается автоматически. Для reactive явный тип обычно не нужен — он выводится из формы переданного объекта.

Generic-компоненты

Иногда компонент работает с «любым типом» — список, который умеет показывать и пользователей, и товары, и сохранять типобезопасность. Для этого у <script setup> есть атрибут generic: вы объявляете параметр-тип и используете его в props и emits.

<script setup lang="ts" generic="T">
// Универсальный список: T — тип одного элемента
defineProps<{
  items: T[]
  getKey: (item: T) => string | number
}>()

const emit = defineEmits<{ select: [item: T] }>()
</script>

<template>
  <ul>
    <li v-for="it in items" :key="getKey(it)" @click="emit('select', it)">
      <slot :item="it" />
    </li>
  </ul>
</template>

Теперь если вы передадите :items="users", то в событии select родитель получит именно User, а не безликий any. Один компонент — много типов, и все они проверяются.

Типизация composables

Composable — это функция; типизируется она как обычная TS-функция. Хорошая практика — описать тип возвращаемого значения, тогда контракт хука виден сразу и не «расползается».

import { ref, computed, type Ref } from "vue"

// Универсальный счётчик с типизированным API
export function useCounter(start = 0) {
  const count: Ref<number> = ref(start)
  const doubled = computed(() => count.value * 2)
  function inc(step = 1) { count.value += step }
  return { count, doubled, inc }
}

// Тип результата выводится: { count: Ref<number>, doubled: ComputedRef<number>, inc: (step?: number) => void }

Generic-хуки тоже частый случай: useFetch<T>(url): { data: Ref<T | null> } — вызывающий указывает форму ответа, и data сразу типизирован под неё. Тип-параметр прокидывает форму данных через всю цепочку.

Как это работает под капотом

defineProps, defineEmits, withDefaults — это макросы компилятора, а не обычные функции. Их обрабатывает компилятор Vue SFC на этапе сборки: он читает переданные TS-типы, генерирует из них рантайм-описание props и проверки, а сами вызовы макросов в собранном коде исчезают. Поэтому их нельзя импортировать и нельзя вызывать условно — компилятор должен видеть их статически.

Сами типы при этом существуют только во время разработки и сборки: после компиляции в браузер уходит обычный JavaScript без единого типа. TypeScript здесь — это «строительные леса»: он ловит ошибки и даёт автодополнение, но в рантайме его нет. Проверку типов в редакторе и при сборке Vue-проектов обеспечивает официальный инструмент vue-tsc вместе с расширением Vue (Volar).

Частые ошибки

  • Забыли lang="ts". Без него <script setup> не понимает TS-синтаксис, и дженерик-форма defineProps<Props>() не работает.
  • Дефолт для массива/объекта не обёрнут в функцию. В withDefaults пишите tags: () => [], иначе все экземпляры разделят один объект.
  • Не указали тип для ref(null). Тогда TypeScript выведет тип null и не даст позже присвоить объект; пишите ref<User | null>(null).
  • Попытка вызвать defineProps условно или импортировать его. Это макрос компилятора — он должен стоять статически на верхнем уровне <script setup>.
  • Ожидание проверки типов в рантайме. Типы стираются при сборке; данные из сети всё равно нужно валидировать руками — TypeScript не проверяет то, что приходит с сервера.

Итоги

  • В <script setup lang="ts"> props задаются дженериком defineProps<Props>(), а дефолты — через withDefaults.
  • События описываются типом в defineEmits: неверное имя или payload становятся ошибкой компиляции.
  • Тип ref обычно выводится из начального значения; указывайте его явно для null и расширенных типов, reactive чаще выводит тип сам.
  • Атрибут generic делает компонент универсальным, сохраняя типобезопасность props и событий.
  • Composables типизируются как обычные функции; defineProps/defineEmits — макросы компилятора, а типы стираются в рантайме (проверка — vue-tsc).
Проверьте себя
1. Как в <script setup lang="ts"> задать значения по умолчанию для типизированных props?
AПередать второй аргумент прямо в defineProps
BОбернуть defineProps в withDefaults и передать объект дефолтов (массивы/объекты — функцией)
CДефолты в TypeScript задать нельзя
DЧерез отдельный вызов defineDefaults
2. Почему для ref, который сейчас null, а позже будет хранить объект User, нужно указать тип явно?
AИначе ref вообще не создастся
BИз ref(null) TypeScript выведет тип null и не позволит позже присвоить объект; ref<User | null>(null) разрешает оба значения
CЯвный тип ускоряет реактивность
DБез типа значение не развернётся в шаблоне
3. Что такое defineProps и defineEmits с точки зрения сборки?
AОбычные функции из пакета vue, которые можно импортировать и вызывать условно
BМакросы компилятора SFC: обрабатываются при сборке, генерируют рантайм-описание, а сами вызовы исчезают из собранного кода
CСерверные функции, выполняемые при каждом запросе
DХелперы роутера для типизации маршрутов