TypeScript во Vue
Vue 3 со Composition API и <script setup lang="ts"> имеет встроенную поддержку TypeScript: типизированные макросы defineProps и defineEmits, generic-компоненты без дженериков в сигнатуре функции и строгая проверка шаблонов через vue-tsc.
В этой статье разберём типизацию пропсов, событий, реактивности, слотов, provide/inject и типичные ошибки — с параллелями на React, чтобы легче переключаться между экосистемами.
Содержание
- Базовая типизация пропсов
- Типизация событий
- Типизация реактивности
- Типизация v-model и форм
- Дженерики в компонентах
- Дочерние элементы: слоты
- Template refs: DOM и компоненты
- Provide/Inject: InjectionKey
- Utility-типы для пропсов
- Discriminated unions для пропсов
- Vue vs React: шпаргалка по типизации
- Проверка типов: vue-tsc
- Типичные ошибки
Базовая типизация пропсов
В React пропсы — это аргумент функции-компонента. Во Vue пропсы объявляются макросом defineProps, а типы задаются через дженерик — без отдельного interface для параметра.
React-аналог: в React —
interface Propsу параметра функции-компонента. Во Vue — макросdefineProps<T>(). Концептуально то же самое, но синтаксис другой.
Через defineProps()
<script setup lang="ts">
interface Props {
title: string;
count?: number;
variant?: "primary" | "secondary" | "danger";
}
const props = defineProps<Props>();
</script>
<template>
<h1 :class="`btn-${props.variant ?? 'primary'}`">{{ props.title }}</h1>
</template>
Inline-тип
Можно не выносить тип в отдельный interface:
<script setup lang="ts">
const props = defineProps<{
title: string;
count?: number;
}>();
</script>
С 3.3 разрешены импортированные и сложные типы — например, defineProps<Props & Extra>().
Дефолтные значения: withDefaults
<script setup lang="ts">
interface Props {
message: string;
type?: "info" | "warning" | "error";
}
const props = withDefaults(defineProps<Props>(), {
type: "info",
});
</script>
withDefaults снимает optional-флаг у пропсов, для которых объявлен дефолт: тип props.type становится "info" | "warning" | "error" без undefined. Дефолты для массивов и объектов обязательно передаются фабриками, чтобы каждый экземпляр получал свою копию: items: () => [].
Деструктуризация пропсов
С Vue 3.5 стабильна реактивная деструктуризация — дефолты пишутся прямо в деструктуризации, и тип сужается как в обычном TS:
<script setup lang="ts">
const { title, type = "info" } = defineProps<Props>();
// type здесь: "info" | "warning" | "error" — без undefined
</script>
React-аналог: в React
function Alert({ type = "info" }: AlertProps)— деструктуризация сразу сужает тип. Во Vue то же самое достигаетсяwithDefaults(черезprops.type) или реактивной деструктуризацией в 3.5+.
Fallthrough-атрибуты и useAttrs
Во Vue необъявленные атрибуты автоматически пробрасываются на корневой элемент (fallthrough attributes) — в отличие от React, где это делается явно.
<script setup lang="ts">
const props = defineProps<{ label: string }>();
const attrs = useAttrs(); // Record<string, unknown>
</script>
<template>
<label>
{{ label }}
<input v-bind="$attrs" />
</label>
</template>
React-аналог: в React атрибуты пробрасываются явно:
...rest+React.InputHTMLAttributes<HTMLInputElement>. Во Vue — автоматический fallthrough +useAttrs()/$attrs.
Типизация событий
Во Vue события компонента объявляются макросом defineEmits и типизируются как «call signature» или именованные кортежи.
React-аналог: в React события — пропсы-функции:
onChange: (value: string) => void. Во Vue — макросdefineEmits<T>(), и TS проверяет и имя события, и payload при вызовеemit.
Синтаксис call signature
<script setup lang="ts">
const emit = defineEmits<{
(e: "change", value: string): void;
(e: "submit", formData: FormData): void;
(e: "close"): void;
}>();
</script>
Именованные кортежи (Vue 3.3+)
Более читаемый вариант:
<script setup lang="ts">
const emit = defineEmits<{
change: [value: string];
submit: [formData: FormData];
close: [];
}>();
</script>
Использование
<template>
<button @click="emit('close')">Close</button>
</template>
TypeScript проверяет и имя события, и payload: emit("change", 42) — ошибка компиляции.
v-model на компоненте
v-model — это синтаксический сахар над парой «пропс modelValue + событие update:modelValue»:
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
React-аналог:
v-model— это пара «пропс + событие», какvalue+onChangeв React. Разница в синтаксисе: в React это два явных пропса, во Vue — макрос.
Типизация реактивности
ref
const count = ref(0); // Ref<number>
const user = ref<User | null>(null); // Ref<User | null>
const items = ref<string[]>([]); // Ref<string[]>
ref() без значения выводит union с undefined: const n = ref<number>() → Ref<number | undefined>.
React-аналог:
useState(0)— то же самое, но возвращает кортеж[state, setState]. Для nullable в React нужен явный дженерикuseState<User | null>(null)— во Vue тот же паттерн:ref<User | null>(null).
reactive
interface FormState {
email: string;
password: string;
}
const form = reactive<FormState>({ email: "", password: "" });
reactive работает только с объектами — для примитивов и union-типов используйте ref. Деструктуризация объекта reactive теряет реактивность, поэтому нужны toRefs или ref.
computed
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0));
// total: ComputedRef<number>
const sorted = computed<string[]>(() => {
return [...items.value].sort((a, b) => a.name.localeCompare(b.name));
});
watch и readonly
watch(source, (value, oldValue) => {
// типы выводятся из источника
});
const config = readonly(ref({ theme: "light" })); // Readonly<Ref<{ theme: string }>>
Типизация v-model и форм
Состояние формы через reactive
interface FormState {
email: string;
password: string;
errors: Partial<Record<"email" | "password", string>>;
isSubmitting: boolean;
}
const form = reactive<FormState>({
email: "",
password: "",
errors: {},
isSubmitting: false,
});
Partial<Record<"email" | "password", string>> — элегантный способ типизировать объект ошибок: каждое поле либо string, либо undefined.
v-model на нативных инпутах
<template>
<input v-model="form.email" />
</template>
Тип v-model на нативном <input> выводится автоматически.
События форм
Во Vue события в шаблоне — нативные DOM-события, поэтому Event приводится явно:
<script setup lang="ts">
function onSubmit(e: Event) {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const email = formData.get("email"); // FormDataEntryValue | null
}
</script>
<template>
<form @submit="onSubmit">
<input name="email" type="email" />
</form>
</template>
React-аналог: в React
onSubmitтипизируется какReact.FormEvent<HTMLFormElement>. Во Vue — нативныйEventс приведениемe.target as HTMLFormElement, потому что у Vue нет синтетических событий.
defineModel (Vue 3.4+)
defineModel заменяет пару «пропс modelValue + emit update:modelValue» одним типизированным ref:
<script setup lang="ts">
const model = defineModel<string>(); // Ref<string | undefined>
const required = defineModel<string>({ required: true }); // Ref<string>
const count = defineModel<number>("count"); // именованная модель — v-model:count
</script>
<template>
<input v-model="model" />
</template>
{ required: true } убирает undefined из типа. Для кастомных модификаторов v-model типизируется второй параметр defineModel:
<script setup lang="ts">
const [model, modifiers] = defineModel<string, "capitalize">();
// modifiers.capitalize: boolean | undefined
</script>
Дженерики в компонентах
Vue 3.3+ поддерживает generic-компоненты через атрибут generic у <script setup>.
React-аналог: в React
function List<T>(props: ListProps<T>)— дженерик прямо в функции. Во Vue — атрибутgeneric="T extends Item"у<script setup lang="ts">. Концептуально идентично.
Базовый пример
<script setup lang="ts" generic="T">
defineProps<{
items: T[];
keyExtractor: (item: T) => string;
}>();
defineEmits<{
(e: "select", item: T): void;
}>();
</script>
<template>
<ul>
<li v-for="item in items" :key="keyExtractor(item)">
<slot :item="item" />
</li>
</ul>
</template>
Тип T выводится из передаваемых пропсов — как и в React.
Ограничение дженерика
<script setup lang="ts" generic="T extends { id: string; label: string }">
defineProps<{
options: T[];
value: T | null;
onChange: (value: T) => void;
}>();
</script>
Несколько дженериков
Значение generic работает как список параметров в <> TypeScript:
<script setup lang="ts" generic="T extends string | number, U extends Item">
defineProps<{
id: T;
list: U[];
}>();
</script>
Дочерние элементы: слоты
Во Vue дочерний контент — это слоты, и они тоже типизируются.
React-аналог: в React дочерние элементы — пропс
children(дефолтный «слот») и именованные пропсыheader: ReactNode. Во Vue —<slot />,<slot name="header" />и scoped-слоты с типизацией черезuseSlots/defineSlots.
Типизация через useSlots
<script setup lang="ts">
import { useSlots } from "vue";
const slots = useSlots(); // Record<string, Slot | undefined>
const hasHeader = Boolean(slots.header); // проверка наличия слота
</script>
useSlots() не знает типов пропсов слотов — только наличие. Для полной типизации нужен defineSlots.
DefineSlots (Vue 3.3+)
<script setup lang="ts">
defineSlots<{
default(props: { item: Item }): any;
header(): any;
footer(): any;
}>();
</script>
defineSlots — макрос только для типов, без runtime-эффекта. Он типизирует scoped-слоты: в родителе #default="{ item }" получит типизированный item. Возвращает тот же объект, что useSlots().
Использование в шаблоне
<template>
<div class="card">
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</div>
</template>
Template refs: DOM и компоненты
Ref на DOM-элемент
<script setup lang="ts">
import { ref, onMounted } from "vue";
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
inputRef.value?.focus();
});
</script>
<template>
<input ref="inputRef" />
</template>
React-аналог: в React
useRef<HTMLInputElement>(null)+ref={inputRef}. Во Vue —ref<HTMLInputElement | null>(null)+ref="inputRef"в шаблоне. Ключевое отличие: во Vue значение лежит в.value, в React — в.current.
useTemplateRef (Vue 3.5+)
Современная альтернатива без ручной связки по имени:
<script setup lang="ts">
import { useTemplateRef, onMounted } from "vue";
const inputRef = useTemplateRef<HTMLInputElement>("input");
onMounted(() => {
inputRef.value?.focus();
});
</script>
<template>
<input ref="input" />
</template>
Ref на компонент + defineExpose
Публичное API дочернего компонента задаётся через defineExpose, а тип экземпляра — через InstanceType<typeof Child>:
<!-- Child.vue -->
<script setup lang="ts">
function reset() {
// ...
}
defineExpose({ reset });
</script>
<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from "vue";
import Child from "./Child.vue";
const childRef = ref<InstanceType<typeof Child> | null>(null);
function handleClick() {
childRef.value?.reset();
}
</script>
React-аналог: в React с React 19 ref — обычный пропс
ref?: React.Ref<HTMLInputElement>, а публичное API черезuseImperativeHandle. Во Vue —defineExpose+InstanceType<typeof Child>.
Provide/Inject: InjectionKey
React-аналог: в React —
createContext<T>+useContext. Во Vue —provide/injectсInjectionKey<T>.
InjectionKey<T> — это Symbol с привязанным типом:
// keys.ts
import type { InjectionKey } from "vue";
export interface Theme {
theme: "light" | "dark";
toggle: () => void;
}
export const ThemeKey: InjectionKey<Theme> = Symbol("theme");
<!-- Provider -->
<script setup lang="ts">
import { provide, ref } from "vue";
import { ThemeKey } from "./keys";
const theme = ref<"light" | "dark">("light");
const toggle = () => {
theme.value = theme.value === "light" ? "dark" : "light";
};
provide(ThemeKey, { theme, toggle });
</script>
<!-- Consumer -->
<script setup lang="ts">
import { inject } from "vue";
import { ThemeKey } from "./keys";
const themeCtx = inject(ThemeKey); // Theme | undefined
</script>
Без InjectionKey — inject("theme") вернёт unknown. С ключом — Theme | undefined (гарантии, что провайдер существует, нет). Убрать undefined можно default-значением: inject(ThemeKey, defaultTheme).
Utility-типы для пропсов
Utility-типы TypeScript работают с пропсами Vue точно так же, как в React.
Partial, Pick, Omit, Required
interface UserCardProps {
name: string;
email: string;
avatar: string;
role: "admin" | "user" | "guest";
}
type EditableUserCard = Partial<UserCardProps>; // для формы редактирования
type UserPreview = Pick<UserCardProps, "name" | "email">; // компактная карточка
type UserWithoutAvatar = Omit<UserCardProps, "avatar">;
type FullUserCard = Required<UserCardProps>;
Использование с defineProps
<script setup lang="ts">
defineProps<Partial<UserCardProps>>();
</script>
React-аналог: то же самое работает в React:
Partial<Props>,Omit<Props, "onSubmit">. Utility-типы — общая часть TypeScript, не зависящая от фреймворка.
Discriminated unions для пропсов
Discriminated union — тип с общим полем-«дискриминатором», по которому TypeScript сужает тип. Работает и в скрипте, и в шаблоне (через v-if).
Пропсы с вариантами
<script setup lang="ts">
type ButtonProps =
| { variant: "link"; href: string }
| { variant: "button"; onClick: () => void };
const props = defineProps<ButtonProps>();
</script>
<template>
<a v-if="props.variant === 'link'" :href="props.href">Link</a>
<button v-else @click="props.onClick">Button</button>
</template>
vue-tsc сужает тип в шаблоне: в ветке v-if известен props.href, в v-else — props.onClick.
Exhaustiveness checking
type Status = "idle" | "loading" | "success" | "error";
function getStatusLabel(status: Status): string {
switch (status) {
case "idle":
return "Ready";
case "loading":
return "Loading...";
case "success":
return "Done";
case "error":
return "Failed";
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}
Паттерн never в default гарантирует, что при добавлении нового значения в union-тип компилятор напомнит обработать его.
React-аналог: идентично — discriminated unions работают одинаково в обеих экосистемах. В React сужение происходит в функции-компоненте (
if/switch), во Vue — в<script>или в шаблоне черезv-if.
Vue vs React: шпаргалка по типизации
| Концепция | Vue | React |
|---|---|---|
| Пропсы компонента | defineProps<{ title: string }>() | interface Props { title: string } + параметр функции |
| Дефолтные пропсы | withDefaults(defineProps(), { title: "Default" }) или деструктуризация (3.5+) | Деструктуризация: { title = "Default" }: Props |
| События (emit) | defineEmits<{ (e: "change", v: string): void }>() | Пропс-функция: onChange: (value: string) => void |
| Слоты | <slot />, <slot name="header" />, defineSlots | children, именованные пропсы: header: ReactNode |
| Ref на DOM | const el = ref<HTMLInputElement | null>(null) | const el = useRef<HTMLInputElement>(null) |
| Provide/Inject | provide<T>(key, value) / inject<T>(key) | createContext<T>() / useContext() |
| Дженерики | <script setup generic="T"> | function Comp<T>(props: Props<T>) |
| События | @click="(e: Event) => ..." | onClick={(e: React.MouseEvent) => ...} |
| Формы | v-model с типизацией, defineModel | value + onChange с React.ChangeEvent |
| Ref-передача | ref="childRef" в шаблоне | ref={childRef} в JSX |
Проверка типов: vue-tsc
Обычный tsc не понимает .vue-файлы. Проверку типов в SFC — включая шаблоны, v-bind, narrowing в v-if и типы слотов — выполняет vue-tsc:
vue-tsc --noEmit # проверка типов
npm run build # в build-скрипте обычно vue-tsc --noEmit && vite build
TS-конфиг наследуется от Vue: extends: "@vue/tsconfig/tsconfig.dom.json".
React-аналог: в React типы проверяет обычный
tsc/tsc --noEmit— JSX проверяется как часть TSX. Во Vue шаблоны вне<script>понимает толькоvue-tsc.
Типичные ошибки
1. ref(null) без дженерика
// ❌ Тип выводится как Ref<null> — присвоить User нельзя
const user = ref(null);
user.value = { name: "Alice" }; // Ошибка!
// ✅ Явный дженерик
const user = ref<User | null>(null);
user.value = { name: "Alice" }; // OK
2. inject без InjectionKey
// ❌ Тип unknown — никакой проверки на месте использования
const theme = inject("theme");
// ✅ Типизированный ключ
const theme = inject(ThemeKey); // Theme | undefined
3. withDefaults в паре с деструктуризацией
<script setup lang="ts">
// ⚠️ eslint (vue/define-props-destructuring) запрещает комбинацию
// withDefaults + деструктуризация; до 3.5 теряется реактивность
const { type } = withDefaults(defineProps<Props>(), { type: "info" });
// ✅ Обращение через props-объект (тип сужен withDefaults)
const props = withDefaults(defineProps<Props>(), { type: "info" });
// ✅ Или реактивная деструктуризация в Vue 3.5+
const { type = "info" } = defineProps<Props>();
</script>
4. reactive с примитивом или union
// ❌ reactive работает только с объектами
const status = reactive("idle");
// ✅ ref для примитивов и union
const status = ref<"idle" | "loading" | "success">("idle");
5. Типизация событий через any
<script setup lang="ts">
// ❌ Теряется типобезопасность
function onSubmit(e: any) { ... }
// ✅ Нативный тип + приведение
function onSubmit(e: Event) { ... }
</script>
6. Template ref на компонент без InstanceType
<script setup lang="ts">
// ❌ ref<Child | null> — Child не тип, vue-tsc ругается
const childRef = ref<Child | null>(null);
// ✅ InstanceType<typeof Child> + defineExpose у ребёнка
const childRef = ref<InstanceType<typeof Child> | null>(null);
</script>
7. Не типизированный v-model на компоненте
<script setup lang="ts">
// ❌ modelValue и update:modelValue без типов — v-model принимает anything
// ✅
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>();
// либо проще:
const model = defineModel<string>();
</script>
8. Ожидание, что tsc проверит шаблоны
# ❌ Ошибки в <template> не будут найдены
tsc --noEmit
# ✅ Шаблоны проверяет только vue-tsc
vue-tsc --noEmit
Ключевые тезисы для интервью
- Пропсы типизируются макросом
defineProps<Props>()— interface, type или inline-тип. withDefaultsзадаёт дефолты в рантайме и снимает optional-флаг у пропсов с дефолтом; с 3.5 предпочтительна реактивная деструктуризация с дефолтами.- События — через
defineEmits<T>(): call signature(e: "change", value: string): voidили именованные кортежи{ change: [value: string] }. v-modelна компоненте — это пропсmodelValue+ событиеupdate:modelValue;defineModel<string>()(3.4+) заменяет эту пару одним типизированным ref.ref<User | null>(null)— явный дженерик для nullable, какuseState<User | null>(null)в React.- Generic-компоненты — атрибут
generic="T extends Item"у<script setup lang="ts">. - Слоты типизируются через
useSlots()(Record<string, Slot | undefined>) иdefineSlots(3.3+). - Template ref на компонент —
ref<InstanceType<typeof Child> | null>(null)+defineExposeу ребёнка. provide/injectтипизируются черезInjectionKey<T>;injectвозвращаетT | undefined.- Шаблоны
.vue(v-bind, v-if narrowing, слоты) проверяет толькоvue-tsc, обычныйtscне понимает SFC.
Заключение
Vue 3 даёт встроенную типизацию через макросы: defineProps для пропсов, defineEmits для событий, defineSlots для слотов, generic для переиспользуемых компонентов. Ключевые паттерны: withDefaults и реактивная деструктуризация для дефолтов, InjectionKey<T> для provide/inject, InstanceType<typeof Child> для template refs на компоненты и vue-tsc для проверки шаблонов. Параллели с React помогают не запутаться в синтаксисе: пропсы-функции против defineEmits, children против слотов, useState против ref. Попробуйте переписать типичный компонент на defineModel и generic — это быстрее всего закрепляет материал.