【TypeScript × Vue 3】Composition API 完全ガイド|defineProps・defineEmits・Composables・Pinia・Vue Routerの型定義を徹底解説

【TypeScript × Vue 3】Composition API 完全ガイド|defineProps・defineEmits・Composables・Pinia・Vue Routerの型定義を徹底解説 HTML/CSS

Vue 3 の Composition API は、TypeScriptとの相性が非常に優れています。Vue 2 の Options API では型推論が効きにくい部分が多かったのですが、Vue 3 では definePropsdefineEmitsrefcomputed などのAPIが最初から型安全に設計されています。

この記事では、Vue 3 + TypeScript の基礎セットアップから、Composition API の型定義パターン、Composables(カスタムフック相当)、状態管理ライブラリの Pinia、ルーティングの Vue Router まで、実務で必要な知識を体系的に解説します。React + TypeScriptReact Hooks の型定義と合わせて読むと、フレームワーク間の型パターンの共通点と違いを理解できます。

この記事で学べること
Vue 3 + TypeScript のプロジェクトセットアップ(Vite)、defineProps・defineEmits の型定義方法、ref・reactive・computed・watch の型安全な使い方、Composables(再利用可能な Composition 関数)の型設計、Pinia ストアの TypeScript 型定義、Vue Router の型安全なルーティング、コンポーネント間通信のベストプラクティスまで網羅
スポンサーリンク

Vue 3 + TypeScript のプロジェクトセットアップ

Vue 3 × TypeScript のプロジェクトは Vite を使って作成するのが現在のスタンダードです。Vite は高速なHMR(ホットモジュールリプレースメント)と TypeScript のネイティブサポートを備えています。

Vite でプロジェクトを作成する

ターミナル
# プロジェクト作成(vue-ts テンプレートを選択)
npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install
npm run dev

または、Vue CLI を使って対話的に選択することもできます。

ターミナル
# Vue CLI でのプロジェクト作成
npm create vue@latest
# → TypeScript: Yes を選択

tsconfig.json の確認

Vite テンプレートには tsconfig.jsontsconfig.node.json が含まれています。重要な設定を確認しておきましょう。

tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",

    /* 厳格な型チェック */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}
重要: useDefineForClassFields
useDefineForClassFields: true は Vue 3 では必須設定です。これがないと、クラスプロパティの初期化動作がVueのリアクティビティシステムと干渉することがあります。

vue-tsc によるVueファイルの型チェック

通常の tsc はVueの .vue ファイルを処理できません。Vue専用の型チェックツール vue-tsc を使います。

ターミナル
# 型チェック実行
npx vue-tsc --noEmit

# package.json に登録
# "type-check": "vue-tsc --noEmit"

script setup と型定義の基礎

Vue 3.2 以降、<script setup> 構文が推奨されています。従来の setup() 関数より簡潔に書けて、TypeScript との相性も良くなっています。

script setup の基本構造

UserCard.vue
<!-- UserCard.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'

// ref は型を自動推論
const count = ref(0)          // Ref<number>
const name = ref('Alice')     // Ref<string>

// 明示的に型を指定する場合
const userId = ref<number | null>(null)

// computed も型推論される
const doubleCount = computed(() => count.value * 2)  // ComputedRef<number>

// 関数
function increment() {
  count.value++
}
</script>

<template>
  <div>
    <p>{{ name }}: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>
lang=”ts” を忘れずに
<script setup lang="ts">lang="ts" は必須です。これを書かないとVSCodeの型補完が動作しません。

ref と reactive の使い分け

API 用途 型パターン アクセス
ref<T>() プリミティブ・単一値 Ref<T> .value 必要
reactive<T>() オブジェクト全体 T(そのまま) .value 不要
computed() 派生値(読み取り専用) ComputedRef<T> .value 必要
shallowRef<T>() 深いリアクティビティ不要 ShallowRef<T> .value 必要
ref と reactive の型定義
import { ref, reactive } from 'vue'

// ref: プリミティブに適切
const count = ref(0)          // Ref<number>
const isOpen = ref(false)     // Ref<boolean>

// オブジェクトも ref で使える(.value でアクセス)
const user = ref({ id: 1, name: 'Alice' })
// user.value.name にアクセス

// reactive: オブジェクトを直接リアクティブに
interface UserState {
  id: number
  name: string
  email: string
}

const userState = reactive<UserState>({
  id: 1,
  name: 'Alice',
  email: 'alice@example.com'
})
// userState.name でアクセス(.value 不要)
reactive の型推論の注意点
reactive() は TypeScript の型推論が弱い場合があります。複雑なオブジェクトには reactive<UserState>({}) のように明示的な型引数を使うことを推奨します。また、reactive に ref をネストすると自動アンラップされます(.value が不要になる)。

watch と watchEffect の型定義

Vue 3 の watchwatchEffect は、リアクティブな値の変化を監視する際に使います。TypeScript では監視する値と変化前後の型が正しく推論されます。

watch の基本的な型定義

watch の型定義パターン
import { ref, watch } from 'vue'

const count = ref(0)        // Ref<number>
const name  = ref('Alice')  // Ref<string>

// 単一の ref を監視(newVal・oldVal の型が自動推論される)
watch(count, (newVal, oldVal) => {
  // newVal: number, oldVal: number
  console.log(`${oldVal} → ${newVal}`)
})

// 複数の ref を配列で監視
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
  // newCount: number, newName: string
  console.log(newCount, newName)
})

// getter 関数で監視(reactive オブジェクトの特定プロパティなど)
const user = ref({ id: 1, name: 'Alice', age: 30 })
watch(
  () => user.value.age,
  (newAge, oldAge) => {
    // newAge: number, oldAge: number
    console.log('年齢変更:', oldAge, '→', newAge)
  }
)

watch のオプションと型

watch のオプション
import { ref, watch } from 'vue'

interface Config {
  theme: 'light' | 'dark'
  language: 'ja' | 'en'
}

const config = ref<Config>({ theme: 'light', language: 'ja' })

// deep: true でオブジェクト内部の変化を監視
watch(
  config,
  (newConfig) => {
    // newConfig: Config(deep でも型推論される)
    console.log('設定変更:', newConfig.theme)
  },
  { deep: true }
)

// immediate: true で即時実行(初期値でも発火)
watch(
  () => config.value.theme,
  (theme) => {
    // theme: 'light' | 'dark'
    document.body.setAttribute('data-theme', theme)
  },
  { immediate: true }
)

// 監視を停止する(返り値の StopHandle を呼ぶ)
const stop = watch(config, () => { /* ... */ })
stop()  // 後で停止

watchEffect の型定義

watchEffect の型定義
import { ref, watchEffect } from 'vue'

const userId = ref<number | null>(null)
const userData = ref<{ name: string } | null>(null)

// watchEffect: 内部で参照したリアクティブ値を自動で追跡
const stop = watchEffect(async () => {
  if (userId.value === null) {
    userData.value = null
    return
  }

  // userId.value を参照しているので、変化時に自動再実行
  const response = await fetch(`/api/users/${userId.value}`)
  userData.value = await response.json()
})

// クリーンアップ(副作用の後処理)
watchEffect((onCleanup) => {
  const timer = setInterval(() => {
    console.log('tick', userId.value)
  }, 1000)

  // コールバック再実行前 or コンポーネント破棄時に実行
  onCleanup(() => clearInterval(timer))
})
watch watchEffect
監視対象 明示的に指定 自動追跡(内部で参照した値)
初回実行 immediate: true で有効化 常に即時実行
変化前の値 oldVal で取得可能 取得不可
用途 特定の値を明示的に監視 副作用を宣言的に書く
watch vs watchEffect の選択基準
変化前後の値(oldVal/newVal)が必要 → watch。「このリアクティブ値を使った副作用」を書きたい場合 → watchEffect が簡潔。DOM 操作・API リクエスト・ログ記録などの副作用には watchEffect + onCleanup の組み合わせが有効です。

defineProps と defineEmits の型定義

コンポーネントの props と emits の型定義は Vue 3 の TypeScript 統合の中でも特に重要です。definePropsdefineEmits を使った型定義パターンを詳しく解説します。

defineProps の型定義方法

props の型定義には2つのアプローチがあります。

defineProps の2つのアプローチ
// ① ランタイム宣言(従来のVue式)
const props = defineProps({
  title: String,
  count: {
    type: Number,
    required: true
  },
  items: {
    type: Array as PropType<string[]>,
    default: () => []
  }
})

// ② 型引数による宣言(TypeScript推奨)
interface Props {
  title: string
  count: number
  items?: string[]
  user?: {
    id: number
    name: string
  }
}

const props = defineProps<Props>()
// props.title → string
// props.count → number
// props.items → string[] | undefined

withDefaults でデフォルト値を設定する

型引数スタイルの defineProps にデフォルト値を付けるには withDefaults を使います。

withDefaults の使い方
interface Props {
  title?: string
  count?: number
  items?: string[]
  variant?: 'primary' | 'secondary' | 'danger'
}

const props = withDefaults(defineProps<Props>(), {
  title: 'デフォルトタイトル',
  count: 0,
  items: () => [],   // 配列・オブジェクトはファクトリ関数で
  variant: 'primary'
})

// props.title → string(undefined にならない)
// props.variant → 'primary' | 'secondary' | 'danger'(型推論される)

defineEmits の型定義方法

defineEmits の型定義
// ① 配列スタイル(型なし)
const emit = defineEmits(['update', 'delete'])

// ② オブジェクト型スタイル(バリデーション付き)
const emit = defineEmits({
  update: (id: number, value: string) => true,
  delete: (id: number) => id > 0
})

// ③ 型引数スタイル(TypeScript推奨)
const emit = defineEmits<{
  update: [id: number, value: string]  // ラベル付きタプル(Vue 3.3+)
  delete: [id: number]
  'update:modelValue': [value: string]  // v-model 対応
}>()

// 使用時
emit('update', 1, '新しい値')  // ✅ 型チェックあり
emit('update', '1', '値')     // ❌ 型エラー(id は number)

v-model の型定義

カスタムコンポーネントの v-model を TypeScript で型安全に実装する方法です。

MyInput.vue(v-model対応)
<!-- MyInput.vue: v-model 対応コンポーネント -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: string
  placeholder?: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    :placeholder="placeholder"
    @input="handleInput"
  />
</template>
親コンポーネント(v-model使用)
<!-- 親コンポーネントでの使用 -->
<script setup lang="ts">
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const username = ref('')
</script>

<template>
  <!-- v-model が型安全に動作 -->
  <MyInput v-model="username" placeholder="ユーザー名を入力" />
  <p>入力値: {{ username }}</p>
</template>
Vue 3.3 の改善点
Vue 3.3 から defineProps でジェネリクスを使えるようになりました(<script setup lang="ts" generic="T">)。また、ラベル付きタプル構文(update: [id: number])が使えるようになり、emit の型が読みやすくなっています。

テンプレートの型安全と型チェック

Vue 3 × TypeScript では、テンプレート内でも型チェックが有効です。vue-tsc を使うことでテンプレートの型エラーを検出できます。

テンプレート内のイベント型

テンプレートのイベント型
<script setup lang="ts">
// イベントハンドラの型定義
function handleClick(event: MouseEvent) {
  console.log(event.clientX, event.clientY)
}

function handleInput(event: Event) {
  // Event から HTMLInputElement にキャスト
  const target = event.target as HTMLInputElement
  console.log(target.value)
}

function handleSubmit(event: SubmitEvent) {
  event.preventDefault()
  // フォームの処理
}
</script>

<template>
  <button @click="handleClick">クリック</button>
  <input @input="handleInput" />
  <form @submit="handleSubmit">...</form>
</template>

v-for と型推論

v-for と型推論
<script setup lang="ts">
interface Todo {
  id: number
  title: string
  completed: boolean
}

const todos: Todo[] = [
  { id: 1, title: 'TypeScriptを学ぶ', completed: false },
  { id: 2, title: 'Vueを学ぶ', completed: true }
]
</script>

<template>
  <!-- todo は Todo 型として推論される -->
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <!-- todo.title → string(型チェックあり) -->
      {{ todo.title }}
      <!-- todo.completed → boolean -->
      <span v-if="todo.completed">✓</span>
    </li>
  </ul>
</template>

template ref の型定義

template ref の型定義
<script setup lang="ts">
import { ref, onMounted } from 'vue'

// HTMLElement の ref
const inputEl = ref<HTMLInputElement | null>(null)

// コンポーネントの ref
import MyModal from './MyModal.vue'
const modalRef = ref<InstanceType<typeof MyModal> | null>(null)

onMounted(() => {
  // null チェックが必要
  inputEl.value?.focus()
  modalRef.value?.open()
})
</script>

<template>
  <input ref="inputEl" type="text" />
  <MyModal ref="modalRef" />
</template>
InstanceType の使い所
InstanceType<typeof MyModal> パターンは、コンポーネントの ref から expose された メソッド・プロパティに型安全にアクセスするために使います。詳しくはReact Hooks の型定義の forwardRef・useImperativeHandle の解説(React版)も参考になります。

defineExpose で公開するAPIを型安全に定義する

defineExpose を使うと、親コンポーネントから子コンポーネントの特定のプロパティ・メソッドにアクセスできるようにできます。

MyModal.vue(defineExpose)
<!-- MyModal.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const isVisible = ref(false)

function open() {
  isVisible.value = true
}

function close() {
  isVisible.value = false
}

// defineExpose で公開するAPIを明示
defineExpose({
  open,
  close,
  isVisible  // 読み取り専用にしたい場合は computed で
})
</script>
親コンポーネント(defineExpose 利用)
<!-- 親コンポーネント -->
<script setup lang="ts">
import { ref } from 'vue'
import MyModal from './MyModal.vue'

// InstanceType で expose されたAPIの型を取得
const modal = ref<InstanceType<typeof MyModal> | null>(null)

function showModal() {
  modal.value?.open()   // open() は型安全にアクセスできる
}
</script>

<template>
  <button @click="showModal">モーダルを開く</button>
  <MyModal ref="modal" />
</template>

Composables(コンポーザブル)の型設計

Composables は Vue 3 のリアクティビティシステムを使った再利用可能な関数です。React の Custom Hooks に相当します。TypeScript で型安全に設計する方法を解説します。

基本的なComposableの型定義

composables/useCounter.ts
// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  const doubleCount = computed(() => count.value * 2)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initialValue }

  return {
    count,          // Ref<number>
    doubleCount,    // ComputedRef<number>
    increment,
    decrement,
    reset
  }
}

// 使用時: 戻り値の型は自動推論される
// const { count, increment } = useCounter(10)

非同期データ取得のComposable

非同期処理を含むComposableは、ローディング・エラー状態も含めて型定義します。非同期処理の型定義の非同期パターンも参考にしてください。

composables/useFetch.ts
// composables/useFetch.ts
import { ref, Ref } from 'vue'

interface FetchState<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: () => Promise<void>
}

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

  async function execute(): Promise<void> {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(url)
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json() as T
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}
useFetch の使用例
<!-- 使用例 -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useFetch } from './composables/useFetch'

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

// T = User として型推論される
const { data: users, loading, error, execute } = useFetch<User[]>(
  'https://api.example.com/users'
)

onMounted(() => execute())
</script>

<template>
  <div v-if="loading">読み込み中...</div>
  <div v-else-if="error">エラー: {{ error.message }}</div>
  <ul v-else>
    <!-- user は User 型として推論される -->
    <li v-for="user in users" :key="user.id">
      {{ user.name }} - {{ user.email }}
    </li>
  </ul>
</template>

ローカルストレージのComposable

composables/useLocalStorage.ts
// composables/useLocalStorage.ts
import { ref, watch, Ref } from 'vue'

export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): Ref<T> {
  // ストレージから初期値を読み込む
  const storedValue = localStorage.getItem(key)
  const initialValue: T = storedValue
    ? (JSON.parse(storedValue) as T)
    : defaultValue

  const value = ref<T>(initialValue) as Ref<T>

  // 値が変わったらストレージに保存
  watch(
    value,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue))
    },
    { deep: true }
  )

  return value
}

// 使用例
// const theme = useLocalStorage('theme', 'light') // Ref<string>
// const settings = useLocalStorage('settings', { fontSize: 16 }) // Ref<{fontSize: number}>

Pinia による型安全な状態管理

Pinia は Vue 3 の公式状態管理ライブラリです。Vuex より TypeScript との相性が格段に良く、型推論が自然に機能します。

Pinia のセットアップ

ターミナル
npm install pinia
main.ts(Pinia登録)
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

Setup Store(Composition API スタイル)

Pinia には Options Store と Setup Store の2つのスタイルがあります。TypeScript の型推論が最も効きやすい Setup Store を推奨します。

stores/userStore.ts(Setup Store)
// stores/userStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

export const useUserStore = defineStore('user', () => {
  // state: ref を使う
  const currentUser = ref<User | null>(null)
  const users = ref<User[]>([])
  const isLoading = ref(false)

  // getters: computed を使う
  const isLoggedIn = computed(() => currentUser.value !== null)
  const adminUsers = computed(() =>
    users.value.filter(u => u.role === 'admin')
  )

  // actions: 関数を定義
  async function fetchUsers(): Promise<void> {
    isLoading.value = true
    try {
      const response = await fetch('/api/users')
      users.value = await response.json() as User[]
    } finally {
      isLoading.value = false
    }
  }

  function login(user: User): void {
    currentUser.value = user
  }

  function logout(): void {
    currentUser.value = null
  }

  // 公開するものを明示的に返す
  return {
    currentUser,
    users,
    isLoading,
    isLoggedIn,
    adminUsers,
    fetchUsers,
    login,
    logout
  }
})
コンポーネントでの Pinia 使用
<!-- コンポーネントでの使用 -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from './stores/userStore'

const userStore = useUserStore()

// storeToRefs: リアクティビティを保ちながら分割代入
// ※ アクションは storeToRefs に含めない
const { currentUser, users, isLoading, isLoggedIn } = storeToRefs(userStore)
const { fetchUsers, login, logout } = userStore

onMounted(() => fetchUsers())
</script>

<template>
  <div v-if="isLoading">読み込み中...</div>
  <div v-else-if="isLoggedIn">
    ようこそ、{{ currentUser?.name }} さん
    <button @click="logout">ログアウト</button>
  </div>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{ user.name }} ({{ user.role }})
    </li>
  </ul>
</template>
storeToRefs が必要な理由
const { currentUser } = userStore と直接分割代入すると、リアクティビティが失われてテンプレートが更新されなくなります。storeToRefs() を使うと ref として取り出されるため、リアクティビティが維持されます。ただしアクション(関数)は storeToRefs に含めず、ストアから直接取り出します。

Pinia プラグインの型定義

Pinia プラグイン(永続化)
// Pinia プラグインで永続化(pinia-plugin-persistedstate の例)
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// ストア定義でオプションを指定
export const useSettingsStore = defineStore('settings', () => {
  const theme = ref<'light' | 'dark'>('light')
  const language = ref<'ja' | 'en'>('ja')
  return { theme, language }
}, {
  persist: true  // ローカルストレージに自動保存
})

Vue Router の型安全なルーティング

Vue Router 4 は TypeScript をネイティブサポートしています。ルートパラメータや名前付きルートを型安全に扱う方法を解説します。

ルーター設定の型定義

router/index.ts
// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('../views/HomeView.vue')
  },
  {
    path: '/users/:id',
    name: 'user-detail',
    component: () => import('../views/UserDetailView.vue'),
    props: true  // params を props として渡す
  },
  {
    path: '/admin',
    component: () => import('../layouts/AdminLayout.vue'),
    meta: { requiresAuth: true, role: 'admin' },
    children: [
      {
        path: '',
        name: 'admin-dashboard',
        component: () => import('../views/AdminDashboard.vue')
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

RouteMeta の型拡張

ルートの meta プロパティに独自の型を追加するには、型宣言ファイルを使います。

src/types/vue-router.d.ts(型拡張)
// src/types/vue-router.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    role?: 'admin' | 'user'
    title?: string
  }
}
ナビゲーションガード(型安全)
// ナビゲーションガードでの使用
router.beforeEach((to, _from, next) => {
  // to.meta.requiresAuth は boolean | undefined として型推論される
  if (to.meta.requiresAuth) {
    const isAuthenticated = checkAuth()
    if (!isAuthenticated) {
      next({ name: 'login' })
      return
    }
  }
  next()
})

useRouter と useRoute の型安全な使用

useRouter・useRoute の型安全な使用
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'

const router = useRouter()
const route = useRoute()

// route.params は Record<string, string | string[]>
// 明示的なキャストが必要な場合
const userId = computed(() => {
  const id = route.params.id
  return Array.isArray(id) ? id[0] : id
})

// 型安全なナビゲーション
function goToUserDetail(id: number) {
  router.push({ name: 'user-detail', params: { id } })
}

function goBack() {
  router.back()
}
</script>
unplugin-vue-router で型安全なルーティングを実現
unplugin-vue-router を使うと、ファイルベースルーティングと型安全なルート名・パラメータが自動生成されます。Nuxt.js 3 のルーティングと同等の型安全性をVue 3 単体でも実現できます。

provide / inject の型安全なパターン

Vue 3 の provide / inject を TypeScript で使う場合、InjectionKey を使って型安全にする必要があります。

composables/useTheme.ts(InjectionKey)
// composables/useTheme.ts
import { provide, inject, ref, Ref, InjectionKey } from 'vue'

type Theme = 'light' | 'dark'

interface ThemeContext {
  theme: Ref<Theme>
  toggleTheme: () => void
}

// InjectionKey で型を紐付ける(Symbol を使う)
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

// Provider側(通常はApp.vueやレイアウトコンポーネント)
export function useThemeProvider() {
  const theme = ref<Theme>('light')

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  provide(ThemeKey, { theme, toggleTheme })

  return { theme, toggleTheme }
}

// Consumer側(子コンポーネント)
export function useTheme() {
  // inject は型が自動推論される(ThemeContext | undefined)
  const context = inject(ThemeKey)
  if (!context) {
    throw new Error('useTheme は ThemeProvider の子コンポーネントで使用してください')
  }
  return context
}
provide/inject の使用例
<!-- App.vue: Provider -->
<script setup lang="ts">
import { useThemeProvider } from './composables/useTheme'
useThemeProvider()
</script>

<!-- ChildComponent.vue: Consumer -->
<script setup lang="ts">
import { useTheme } from './composables/useTheme'
const { theme, toggleTheme } = useTheme()
</script>

<template>
  <div :class="`theme-${theme}`">
    <button @click="toggleTheme">
      {{ theme === 'light' ? 'ダークモード' : 'ライトモード' }}に切り替え
    </button>
  </div>
</template>

実務で使える型定義パターン集

コンポーネントのProps型をエクスポートして再利用

Props型のエクスポート
<!-- ButtonComponent.vue -->
<script setup lang="ts">
export interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
}

const props = withDefaults(defineProps<ButtonProps>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
  loading: false
})
</script>
Props型の再利用
// 他のコンポーネントやComposableで再利用
import type { ButtonProps } from './ButtonComponent.vue'

// フォームのボタン設定など
interface FormConfig {
  submitButton: ButtonProps
  cancelButton: ButtonProps
}

const formConfig: FormConfig = {
  submitButton: { label: '保存する', variant: 'primary', loading: false },
  cancelButton: { label: 'キャンセル', variant: 'secondary' }
}

非同期コンポーネントとエラーハンドリング

非同期コンポーネント
// 非同期コンポーネントの型安全な定義
import { defineAsyncComponent } from 'vue'

const AsyncUserList = defineAsyncComponent({
  loader: () => import('./components/UserList.vue'),
  loadingComponent: () => import('./components/LoadingSpinner.vue'),
  errorComponent: () => import('./components/ErrorMessage.vue'),
  delay: 200,         // ローディング表示までの遅延(ms)
  timeout: 10000      // タイムアウト(ms)
})

Generic コンポーネント(Vue 3.3+)

Vue 3.3 以降では、コンポーネント自体にジェネリクスを持たせることができます。

SelectComponent.vue(Generic)
<!-- SelectComponent.vue -->
<script setup lang="ts" generic="T extends { id: number; label: string }">
interface Props {
  items: T[]
  modelValue: T | null
  placeholder?: string
}

interface Emits {
  'update:modelValue': [value: T]
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '選択してください'
})

const emit = defineEmits<Emits>()
</script>

<template>
  <select
    :value="modelValue?.id ?? ''"
    @change="(e) => {
      const id = Number((e.target as HTMLSelectElement).value)
      const selected = items.find(item => item.id === id)
      if (selected) emit('update:modelValue', selected)
    }"
  >
    <option value="">{{ placeholder }}</option>
    <option v-for="item in items" :key="item.id" :value="item.id">
      {{ item.label }}
    </option>
  </select>
</template>
Generic コンポーネントの使用例
<!-- 使用例 -->
<script setup lang="ts">
import { ref } from 'vue'
import SelectComponent from './SelectComponent.vue'

interface Category {
  id: number
  label: string
  color: string  // 追加プロパティ
}

const categories: Category[] = [
  { id: 1, label: 'TypeScript', color: 'blue' },
  { id: 2, label: 'Vue.js', color: 'green' }
]

const selectedCategory = ref<Category | null>(null)
</script>

<template>
  <!-- T = Category として型推論される -->
  <SelectComponent
    v-model="selectedCategory"
    :items="categories"
    placeholder="カテゴリを選択"
  />
</template>

よくある質問(FAQ)

QOptions API と Composition API、TypeScriptとの相性はどちらが良いですか?
AComposition API の方が TypeScript との相性が格段に良いです。Options API では this の型推論が複雑で、Mixins を使うと型が失われることがあります。Composition API は関数ベースのため型推論が自然に機能し、Composables による再利用も型安全に行えます。新規プロジェクトでは Composition API の使用を強く推奨します。
Qvue-tsc と tsc の違いは何ですか?
Atsc は通常の TypeScript ファイル(.ts)しか処理できません。vue-tsc.vue ファイルのテンプレートも含めて型チェックできます。CI/CD パイプラインでは必ず vue-tsc --noEmit を使ってください。ただし vue-tsc は型チェックのみで、実際のコンパイルは Vite が行います。
Qref と reactive はどちらを使うべきですか?
A一般的には ref を使うことを推奨します。理由は(1).value アクセスで明示的にリアクティブな値だとわかる、(2)プリミティブとオブジェクト両方に使える、(3)Composables からの返り値として扱いやすい、の3点です。reactive はオブジェクト全体をリアクティブにしたいとき、かつ .value を書くのが煩わしい場合に使うと良いでしょう。
QPinia と Vuex の使い分けはどうすれば良いですか?
AVue 3 の新規プロジェクトでは Pinia を強く推奨します。Pinia は(1)TypeScript の型推論が自然、(2)mutations が不要でシンプル、(3)DevTools サポートが充実、(4)Vue 公式が推奨、の利点があります。Vuex 5(Vue 3対応版)の開発は中断されており、Pinia が事実上の後継となっています。既存の Vuex 4 プロジェクトの移行も公式ガイドが提供されています。
QVue 3 + TypeScript でパフォーマンスを最適化するには?
A主な対策は(1)defineAsyncComponent で遅延ロード、(2)v-memo ディレクティブで再レンダリング抑制、(3)大きなリストには v-for + :key を適切に設定、(4)shallowRefshallowReactive で深いリアクティビティを避ける、(5)computed の積極活用(副作用なしの値は watch より computed を使う)、の5点です。

まとめ

Vue 3 + TypeScript の型定義パターンをまとめます。

機能 推奨パターン ポイント
Props定義 defineProps<Interface>() withDefaults でデフォルト値
Emits定義 defineEmits<{event: [args]}>() ラベル付きタプル(Vue 3.3+)
リアクティブ値 ref<T>() を基本に reactive はオブジェクト全体向け
派生値 computed() 副作用なしなら watch より優先
再利用ロジック Composables(use〇〇関数) InjectionKey で型安全なprovide/inject
状態管理 Pinia(Setup Store) storeToRefs でリアクティビティ保持
ルーティング RouteRecordRaw[] + RouteMeta拡張 unplugin-vue-router で更に型安全に
公開API defineExpose() InstanceType で親から型安全アクセス
次のステップ
Vue 3 + TypeScript をさらに深めるには、ジェネリクス完全ガイドを読んでComposablesのジェネリクス設計を学ぶのが効果的です。また、型の絞り込み(Type Narrowing)を理解しておくと、テンプレートやComposable内での型絞り込みがスムーズになります。バックエンドとの連携は非同期処理の型定義の非同期処理パターンも参考にしてください。