Урок 02

Типизация пропсов и событий

defineProps с типами

Самое удобное — interface:

interface Props {
    title: string;
    count: number;
    isActive: boolean;
    items: string[];
    user: User;
}

const props = defineProps<Props>();

Автодополнение и проверка работают.

Опциональные props

interface Props {
    title: string;
    subtitle?: string;
    count?: number;
}

? — необязательный.

Дефолты через withDefaults

interface Props {
    title: string;
    count?: number;
    items?: Item[];
    user?: User | null;
}

const props = withDefaults(defineProps<Props>(), {
    count: 0,
    items: () => [],
    user: null,
});

⚠️ Объекты и массивы — через функцию, иначе все экземпляры разделят один объект.

Тип из одного места

types/index.ts:

export interface User {
    id: number;
    name: string;
    email: string;
}

export interface ProductCardProps {
    product: Product;
    showPrice?: boolean;
    onAdd?: (product: Product) => void;
}

Компонент:

<script setup lang="ts">
import type { ProductCardProps } from '@/types';

const props = withDefaults(defineProps<ProductCardProps>(), {
    showPrice: true,
});
</script>

Реактивная деструктуризация

С Vue 3.5+ можно деструктуризировать:

const { title, count = 0 } = defineProps<Props>();

Работает реактивно.

До 3.5 — через props.title.

Тип пропсов без interface

const props = defineProps<{
    title: string;
    count?: number;
    items: Item[];
}>();

Работает, но переиспользовать сложнее.

Тип пропсов через type

type Props = {
    title: string;
    count?: number;
};

const props = defineProps<Props>();

type vs interface:

  • interface — можно наследовать
  • type — можно union, intersection

Для props обычно interface.

defineEmits с типами

Синтаксис объекта:

type Emits = {
    (e: 'save', id: number): void;
    (e: 'cancel'): void;
    (e: 'update', payload: { id: number; name: string }): void;
};

const emit = defineEmits<Emits>();

Сокращённый синтаксис (3.3+):

const emit = defineEmits<{
    save: [id: number];
    cancel: [];
    update: [payload: { id: number; name: string }];
}>();

Что даёт:

  • Автодополнение при emit('...')
  • Проверка аргументов
  • Ошибка при опечатке

Именованные параметры

const emit = defineEmits<{
    update: [id: number, name: string];   // позиционные
    save: [payload: { id: number; name: string }];   // именованный объект
}>();

emit('update', 5, 'Иван');
emit('save', { id: 5, name: 'Иван' });

Комбинированный пример

<script setup lang="ts">
import type { User } from '@/types';

interface Props {
    user: User;
    editable?: boolean;
    onDelete?: (id: number) => void;
}

type Emits = {
    save: [user: User];
    delete: [id: number];
    cancel: [];
};

const props = withDefaults(defineProps<Props>(), {
    editable: false,
});

const emit = defineEmits<Emits>();

function handleSave() {
    emit('save', props.user);
}

function handleDelete() {
    emit('delete', props.user.id);
}
</script>

<template>
    <div>
        <h3>{{ user.name }}</h3>
        <p>{{ user.email }}</p>

        <button v-if="editable" @click="handleSave">Сохранить</button>
        <button v-if="editable" @click="handleDelete">Удалить</button>
        <button @click="emit('cancel')">Отмена</button>
    </div>
</template>

defineModel с типами

// Простой v-model
const model = defineModel<string>();
model.value = 'new value';   // string

// С дефолтом
const model = defineModel<string>({ default: '' });

// Обязательный
const model = defineModel<string>({ required: true });

// Именованный
const count = defineModel<number>('count', { default: 0 });

Модификаторы:

const [model, modifiers] = defineModel<string>({
    set(value) {
        return modifiers.trim ? value.trim() : value;
    },
});

Тип emit в родителе

<script setup lang="ts">
import UserCard from './UserCard.vue';
import type { User } from '@/types';

const user = ref<User>({ id: 1, name: 'Иван', email: 'ivan@example.com' });

function handleSave(u: User) {
    console.log('Saved', u);
}

function handleDelete(id: number) {
    console.log('Deleted', id);
}
</script>

<template>
    <UserCard
        :user="user"
        :editable="true"
        @save="handleSave"
        @delete="handleDelete"
        @cancel="() => {}"
    />
</template>

TS проверит типы обработчиков.

Проброс emits вверх

Если компонент-обёртка:

<script setup lang="ts">
import InnerButton from './InnerButton.vue';

defineEmits<{
    click: [event: MouseEvent];
}>();
</script>

<template>
    <InnerButton @click="$emit('click', $event)" />
</template>

Или через defineOptions({ inheritAttrs: false }) + v-bind="$attrs".

Тип слота

defineSlots<{
    default: (props: { item: Item }) => any;
    header: () => any;
    footer: (props: { total: number }) => any;
}>();

Родитель:

<MyList :items="items">
    <template #default="{ item }">
        {{ item.name }}
    </template>
</MyList>

Проблема с Boolean props

Плохо:

<MyComponent :disabled="true" />

Хорошо:

<MyComponent disabled />

Или:

<MyComponent :disabled="isDisabled" />

⚠️ :disabled="false" — не работает, используй :disabled="false" или без атрибута.

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

1. Забыл lang="ts" — типы игнорируются.

2. interface внутри defineProps:

// ❌ Нельзя
const props = defineProps<interface Props { title: string }>();

// ✅ Можно
interface Props { title: string; }
const props = defineProps<Props>();

3. Импорт типов через import:

// ❌ Работает не везде
import { User } from '@/types';
const props = defineProps<{ user: User }>();

// ✅ Надёжнее
import type { User } from '@/types';

4. Дефолт объекта без функции:

// ❌ Все компоненты разделят объект
withDefaults(defineProps<Props>(), { items: [] });

// ✅
withDefaults(defineProps<Props>(), { items: () => [] });

Итог

  • defineProps<Props>() — типизация props
  • withDefaults — дефолты
  • defineEmits<{...}>() — сокращённый синтаксис (3.3+)
  • defineModel<T>() — типизация v-model
  • import type — для типов

Дальше — дженерик-компоненты.