Средний 14 мин чтения

TypeScript с Vue

Типизация Vue 3-компонентов — defineProps, defineEmits, defineExpose, composables, reactive, ref, provide/inject и работа с Nuxt.

typescriptvuedefinePropsdefineEmitscomposablesnuxt

Vue 3 и TypeScript

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

defineProps

Декларативный синтаксис

<script setup lang="ts">
defineProps<{
  title: string
  count?: number
  items: string[]
}>()
</script>

Знак ? делает свойство необязательным.

С значениями по умолчанию

<script setup lang="ts">
const props = withDefaults(defineProps<{
  title: string
  count?: number
  variant?: 'primary' | 'secondary'
}>(), {
  count: 0,
  variant: 'primary',
})
</script>

Реактивные props

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
  disabled?: boolean
}>()

// props — readonly, деструктуризация с потерей реактивности
const { modelValue } = props // теряет реактивность!

// Используйте toRefs
const { modelValue } = toRefs(props) // реактивно
</script>

Интерфейсы из отдельного файла

// types.ts
export interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
}
<script setup lang="ts">
import type { ButtonProps } from './types'

defineProps<ButtonProps>()
</script>

defineEmits

Базовый синтаксис

<script setup lang="ts">
const emit = defineEmits<{
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}>()

emit('update', 'новое значение')
emit('delete', 42)
</script>

Альтернативный синтаксис (Vue 3.3+)

<script setup lang="ts">
const emit = defineEmits<{
  update: [value: string]
  delete: [id: number]
  change: []
}>()

emit('update', 'новое значение')
emit('delete', 42)
emit('change')
</script>

v-model с типами

<!-- CustomInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>

<template>
  <input v-model="model" />
</template>
<!-- Использование -->
<script setup lang="ts">
const name = ref('Анна')
</script>

<template>
  <CustomInput v-model="name" />
</template>

Несколько v-model:

<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>

defineExpose

Определяет, что компонент раскрывает наружу через template ref:

<script setup lang="ts">
const count = ref(0)

const increment = () => count.value++

defineExpose({
  count,
  increment,
})
</script>
<script setup lang="ts">
const counter = ref()

onMounted(() => {
  counter.value.increment()
  console.log(counter.value.count)
})
</script>

<template>
  <Counter ref="counter" />
</template>

ref и reactive

const name = ref<string>('Анна')
const count = ref<number>(0)

// TypeScript выводит тип автоматически
const items = ref(['a', 'b', 'c']) // Ref<string[]>

// Для сложных объектов
interface User {
  id: number
  name: string
  email: string
}

const user = ref<User | null>(null)

reactive:

const state = reactive<{
  loading: boolean
  data: User[]
  error: string | null
}>({
  loading: false,
  data: [],
  error: null,
})

Computed

const fullName = computed<string>(() => `${firstName.value} ${lastName.value}`)

const filteredItems = computed(() =>
  items.value.filter(item => item.active)
)

Тип выводится автоматически, но можно указать явно:

const total = computed<number>(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

Composables

Composable — функция, использующая Composition API. С TypeScript они становятся ещё мощнее:

// composables/useUser.ts
interface User {
  id: number
  name: string
  email: string
}

export function useUser() {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function fetchUser(id: number) {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(`/api/users/${id}`)
      user.value = await response.json()
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Ошибка'
    } finally {
      loading.value = false
    }
  }

  return { user, loading, error, fetchUser }
}
<script setup lang="ts">
const { user, loading, fetchUser } = useUser()

onMounted(() => fetchUser(1))
</script>

Универсальный composable с дженериком

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function execute() {
    loading.value = true
    try {
      const res = await fetch(url)
      data.value = await res.json()
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Ошибка'
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}
<script setup lang="ts">
const { data: users, execute } = useFetch<User[]>('/api/users')
</script>

Provide / Inject

// symbols.ts
export const UserKey: InjectionKey<Ref<User | null>> = Symbol('user')
export const ThemeKey: InjectionKey<'light' | 'dark'> = Symbol('theme')
// Provider
provide(UserKey, user)
provide(ThemeKey, 'dark')
// Consumer
const user = inject(UserKey) // Ref<User | null> | undefined
const theme = inject(ThemeKey, 'light') // 'light' | 'dark'

Использование InjectionKey гарантирует типобезопасность — TypeScript проверит, что типы provide и inject совпадают.

Template refs

<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)

function focus() {
  inputRef.value?.focus()
}
</script>

<template>
  <input ref="inputRef" />
</template>

Для компонента:

<script setup lang="ts">
const modal = ref<InstanceType<typeof Modal> | null>(null)

function open() {
  modal.value?.show()
}
</script>

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

<script setup lang="ts">
function handleClick(event: MouseEvent) {
  console.log(event.clientX, event.clientY)
}

function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  console.log(target.value)
}

function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Enter') {
    // ...
  }
}
</script>

<template>
  <button @click="handleClick">Клик</button>
  <input @input="handleInput" @keydown="handleKeydown" />
</template>

Nuxt и TypeScript

Nuxt полностью поддерживает TypeScript. Автоимпорты работают с типами из коробки.

Типизация API-роутов

// server/api/users/index.ts
export default defineEventHandler(async (event): Promise<User[]> => {
  const users = await db.users.findMany()
  return users
})
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const user = await db.users.findById(Number(id))

  if (!user) {
    throw createError({ statusCode: 404, message: 'Не найден' })
  }

  return user
})

useAsyncData

<script setup lang="ts">
const { data: users, pending, error } = await useAsyncData<User[]>(
  'users',
  () => $fetch('/api/users'),
)
</script>

useFetch

<script setup lang="ts">
const { data, pending } = await useFetch<User[]>('/api/users')
const { data: user } = await useFetch<User>(`/api/users/${id}`)
</script>

Практический пример — типизированный Table

<script setup lang="ts">
interface Column<T> {
  key: keyof T
  label: string
  format?: (value: T[keyof T]) => string
}

interface Props<T> {
  items: T[]
  columns: Column<T>[]
  loading?: boolean
}

const props = withDefaults(defineProps<Props<User>>(), {
  loading: false,
})

const emit = defineEmits<{
  rowClick: [item: User]
  sort: [key: keyof User]
}>()
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns" :key="String(col.key)" @click="emit('sort', col.key)">
          {{ col.label }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="item in items" :key="item.id" @click="emit('rowClick', item)">
        <td v-for="col in columns" :key="String(col.key)">
          {{ col.format ? col.format(item[col.key]) : item[col.key] }}
        </td>
      </tr>
    </tbody>
  </table>
</template>

Итог

Vue 3 и TypeScript отлично работают вместе. defineProps и defineEmits с generic-синтаксисом дают полную типобезопасность компонентов. Composables с дженериками (useFetch<T>) переиспользуются между проектами. InjectionKey гарантирует совпадение типов в provide/inject. В Nuxt типизация API-роутов и useFetch работает из коробки.