Перейти к содержимому

27. TypeScript в Vue 3

TypeScript во Vue 3 — это не просто аннотации типов, это полноценная система, которая делает твои компоненты самодокументируемыми и защищает от целых классов ошибок. <script setup lang="ts"> в связке с Composition API — идеальная пара для типобезопасной разработки. 🚀


Окно терминала
npm create vue@latest my-app
# ✔ Add TypeScript? → Yes
# Или добавить в существующий
npm install -D typescript vue-tsc
// tsconfig.json — рекомендуемая конфигурация
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
// vite.config.ts — для проверки типов при сборке
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { checker } from 'vite-plugin-checker';
export default defineConfig({
plugins: [
vue(),
checker({ vueTsc: true }), // Проверка типов в dev режиме
],
});

<script setup lang="ts">
// Способ 1: Generic типы (рекомендуется)
const props = defineProps<{
title: string;
count: number;
items: string[];
user: { id: number; name: string };
variant?: 'primary' | 'secondary' | 'danger';
onClick?: (id: number) => void;
}>();
// Значения по умолчанию через withDefaults
const props2 = withDefaults(defineProps<{
title: string;
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
items?: string[];
}>(), {
size: 'md',
disabled: false,
items: () => [],
});
</script>
// Способ 2: Через PropType (для совместимости с Options API)
import { defineComponent, PropType } from 'vue';
interface User {
id: number;
name: string;
role: 'admin' | 'user';
}
export default defineComponent({
props: {
user: {
type: Object as PropType<User>,
required: true,
},
tags: {
type: Array as PropType<string[]>,
default: () => [],
},
},
});

<script setup lang="ts">
// Типизированные эмиты — Vue 3.3+
const emit = defineEmits<{
change: [value: string];
submit: [data: FormData, isValid: boolean];
update: [id: number, field: string, value: unknown];
close: [];
}>();
// Использование — TypeScript проверит типы аргументов!
emit('change', 'new value');
emit('submit', new FormData(), true);
emit('close');
// ❌ Ошибка TypeScript:
// emit('change', 123); // number вместо string
// emit('submit', new FormData()); // не хватает isValid
</script>
<!-- Старый синтаксис (до Vue 3.3) — тоже работает -->
<script setup lang="ts">
const emit = defineEmits({
change: (value: string) => typeof value === 'string',
submit: (data: FormData) => data instanceof FormData,
});
</script>

ChildComponent.vue
<script setup lang="ts">
// Экспортируем только нужный публичный интерфейс
defineExpose({
reset: () => { /* ... */ },
focus: () => { /* ... */ },
getValue: (): string => 'some value',
});
</script>
ParentComponent.vue
<script setup lang="ts">
import ChildComponent from './ChildComponent.vue';
// Тип ref — автоматически выводится из defineExpose!
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null);
function handleClick() {
// TypeScript знает, что эти методы существуют
childRef.value?.reset();
childRef.value?.focus();
const val = childRef.value?.getValue();
}
</script>
<template>
<ChildComponent ref="childRef" />
</template>
// ComponentPublicInstance — для динамических компонентов
import { ComponentPublicInstance } from 'vue';
// Тип для любого Vue компонента
type AnyComponent = ComponentPublicInstance<
{}, // Props
{}, // RawBindings
{}, // D (data)
{}, // C (computed)
{} // M (methods)
>;
// Пример: хук для типизированных рефов
function useComponentRef<T extends ComponentPublicInstance>() {
return ref<T | null>(null);
}

import { defineComponent, PropType } from 'vue';
// Сложные вложенные типы
interface Config {
endpoint: string;
timeout: number;
headers: Record<string, string>;
}
type Status = 'loading' | 'success' | 'error';
interface TableColumn<T = unknown> {
key: keyof T;
label: string;
sortable?: boolean;
formatter?: (value: T[keyof T]) => string;
}
export default defineComponent({
props: {
config: {
type: Object as PropType<Config>,
required: true,
},
status: {
type: String as PropType<Status>,
default: 'loading',
},
columns: {
type: Array as PropType<TableColumn[]>,
default: () => [],
},
// Функция с параметрами
onSelect: {
type: Function as PropType<(id: number, item: unknown) => void>,
},
},
});

src/composables/useInjectionKeys.ts
import { InjectionKey, Ref } from 'vue';
// Создаём типизированные ключи
interface UserService {
currentUser: Ref<User | null>;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
interface ThemeService {
theme: Ref<'light' | 'dark'>;
toggleTheme: () => void;
}
// InjectionKey<T> — типизированный символ
export const USER_SERVICE_KEY = Symbol('userService') as InjectionKey<UserService>;
export const THEME_KEY = Symbol('theme') as InjectionKey<ThemeService>;
<!-- RootApp.vue — provider -->
<script setup lang="ts">
import { provide, ref } from 'vue';
import { USER_SERVICE_KEY, THEME_KEY } from '@/composables/useInjectionKeys';
const currentUser = ref<User | null>(null);
const theme = ref<'light' | 'dark'>('light');
// TypeScript проверяет, что ты предоставляешь правильный тип
provide(USER_SERVICE_KEY, {
currentUser,
login: async (credentials) => { /* ... */ },
logout: () => { currentUser.value = null; },
});
provide(THEME_KEY, {
theme,
toggleTheme: () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
},
});
</script>
<!-- DeepChild.vue — consumer -->
<script setup lang="ts">
import { inject } from 'vue';
import { USER_SERVICE_KEY, THEME_KEY } from '@/composables/useInjectionKeys';
// inject возвращает UserService | undefined
const userService = inject(USER_SERVICE_KEY);
const themeService = inject(THEME_KEY);
// Или с дефолтным значением (тогда undefined исключён)
const userServiceRequired = inject(USER_SERVICE_KEY, {
currentUser: ref(null),
login: async () => {},
logout: () => {},
});
</script>

stores/userStore.ts
import { defineStore } from 'pinia';
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
preferences: {
theme: 'light' | 'dark';
language: 'ru' | 'en';
notifications: boolean;
};
}
interface UserState {
currentUser: User | null;
isLoading: boolean;
error: string | null;
}
// Composition API стиль (рекомендуется — лучший TypeScript вывод)
export const useUserStore = defineStore('user', () => {
const currentUser = ref<User | null>(null);
const isLoading = ref(false);
const error = ref<string | null>(null);
// Getters — просто computed
const isLoggedIn = computed(() => currentUser.value !== null);
const isAdmin = computed(() => currentUser.value?.role === 'admin');
const displayName = computed(() => currentUser.value?.name ?? 'Гость');
// Actions — просто функции
async function fetchUser(id: number): Promise<void> {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(\`/api/users/\${id}\`);
if (!response.ok) throw new Error('Ошибка загрузки пользователя');
currentUser.value = await response.json() as User;
} catch (e) {
error.value = e instanceof Error ? e.message : 'Неизвестная ошибка';
} finally {
isLoading.value = false;
}
}
function updatePreferences(prefs: Partial<User['preferences']>): void {
if (!currentUser.value) return;
currentUser.value.preferences = {
...currentUser.value.preferences,
...prefs,
};
}
function logout(): void {
currentUser.value = null;
}
return {
// State
currentUser,
isLoading,
error,
// Getters
isLoggedIn,
isAdmin,
displayName,
// Actions
fetchUser,
updatePreferences,
logout,
};
});
// Тип для storeToRefs
export type UserStore = ReturnType<typeof useUserStore>;
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/userStore';
const store = useUserStore();
// storeToRefs — превращает state/getters в реактивные рефы
// TypeScript автоматически выводит все типы!
const { currentUser, isLoading, isAdmin, displayName } = storeToRefs(store);
// Actions деструктурируем напрямую
const { fetchUser, logout, updatePreferences } = store;
</script>

<!-- GenericList.vue — компонент с дженериком -->
<script setup lang="ts" generic="T extends { id: number }">
// T — дженерик тип, известный на уровне компонента
const props = defineProps<{
items: T[];
keyField?: keyof T;
selected?: T | null;
}>();
const emit = defineEmits<{
select: [item: T];
}>();
function handleSelect(item: T) {
emit('select', item);
}
</script>
<template>
<ul>
<li
v-for="item in items"
:key="item.id"
@click="handleSelect(item)"
>
<slot :item="item" />
</li>
</ul>
</template>
<!-- Использование с автоматическим выводом типов -->
<template>
<!-- T = User — автоматически! -->
<GenericList
:items="users"
:selected="selectedUser"
@select="handleSelectUser"
>
<template #default="{ item }">
<!-- item автоматически типизирован как User! -->
<span>{{ item.name }} ({{ item.role }})</span>
</template>
</GenericList>
</template>
<script setup lang="ts">
interface User { id: number; name: string; role: string; }
const users: User[] = [];
const selectedUser = ref<User | null>(null);
// TypeScript знает, что это User!
function handleSelectUser(user: User) {
selectedUser.value = user;
}
</script>

composables/useFetch.ts
import { ref, Ref } from 'vue';
interface FetchState<T> {
data: Ref<T | null>;
error: Ref<string | null>;
isLoading: Ref<boolean>;
execute: () => Promise<void>;
}
export function useFetch<T>(url: string | Ref<string>): FetchState<T> {
const data = ref<T | null>(null) as Ref<T | null>;
const error = ref<string | null>(null);
const isLoading = ref(false);
const execute = async () => {
isLoading.value = true;
error.value = null;
try {
const resolvedUrl = typeof url === 'string' ? url : url.value;
const response = await fetch(resolvedUrl);
if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
data.value = await response.json() as T;
} catch (e) {
error.value = e instanceof Error ? e.message : 'Ошибка';
} finally {
isLoading.value = false;
}
};
execute();
return { data, error, isLoading, execute };
}
<script setup lang="ts">
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
// TypeScript автоматически выводит тип data как Ref<Post[] | null>
const { data: posts, isLoading, error } = useFetch<Post[]>('/api/posts');
</script>

import {
MaybeRef, // T | Ref<T>
MaybeRefOrGetter, // T | Ref<T> | (() => T)
ComputedRef, // только для computed
WritableComputedRef, // для computed с setter
ShallowRef, // для shallowRef
ToRefs, // ReturnType<typeof toRefs>
UnwrapRef, // Разворачивает Ref<T> → T
} from 'vue';
// MaybeRef — параметр может быть значением или ref
function useTitle(title: MaybeRef<string>) {
watchEffect(() => {
document.title = isRef(title) ? title.value : title;
});
}
useTitle('Статичный заголовок');
useTitle(ref('Реактивный заголовок'));

ФичаКак использовать
PropsdefineProps<{ name: string }>()
EmitsdefineEmits<{ click: [id: number] }>()
Refsref<User | null>(null)
Computedcomputed<string>(() => ...)
Provide/InjectInjectionKey<T>
StoredefineStore с Composition API
Generic компоненты<script setup generic="T">
ComposablesЯвные возвращаемые типы

TypeScript во Vue 3 — это инвестиция, которая окупается с первого дня. Автодополнение, ловля ошибок до рантайма, самодокументируемый код — всё это делает разработку быстрее и надёжнее. 🎯