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