Vue 3.3(2023年5月11日リリース、コードネーム「Rurouni Kenshin」)でgeneric属性が追加され、SFC(単一ファイルコンポーネント)で型パラメータを宣言できるようになりました。従来はPropsにunknown[]anyを指定せざるを得なかった汎用コンポーネントでも、呼び出し側のデータ型をそのまま推論・伝搬できます。

ジェネリクス登場以前の型安全性の課題

汎用的なリスト表示コンポーネントを例に考えます。商品一覧・ユーザー一覧・通知一覧など、データ構造が異なるリストを1つのコンポーネントで描画したい場面は頻繁に発生します。

<!-- GenericなしのListコンポーネント -->
<script setup lang="ts">
const props = defineProps<{
  items: unknown[]
}>()

const emit = defineEmits<{
  select: [item: unknown]
}>()
</script>

itemsunknown[]で受け取ると、呼び出し側で@selectイベントのペイロードもunknownになります。型ガードを書くか、asでキャストするか、型ごとにコンポーネントを複製するかの三択になり、いずれも保守性が低下します。

回避策問題点
anyに変更型チェックが無効化される
asキャスト実行時に型が合わないリスクを見逃す
型ごとにコンポーネントを複製DRY原則に反し、ロジック重複が増大する
ユニオン型で列挙型が増えるたびに定義の修正が必要になる

generic属性の基本構文

<script setup>タグにgeneric属性を付与すると、TypeScriptの型パラメータをコンポーネントに宣言できます。

<script setup lang="ts" generic="T">
defineProps<{
  items: T[]
  selected: T
}>()
</script>

呼び出し側でPropsにデータを渡すと、Vue(内部的にはVolar / vue-tsc)がTの具体型を推論します。

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

interface Product {
  id: number
  name: string
  price: number
}

const products: Product[] = [
  { id: 1, name: 'キーボード', price: 12000 },
  { id: 2, name: 'マウス', price: 5000 },
]
</script>

<template>
  <!-- T  Product に推論される -->
  <MyList :items="products" :selected="products[0]" />
</template>

itemsProduct[]を渡した時点でT = Productと確定するため、selectedにもProduct型しか指定できなくなります。

extends制約で受け付ける型を限定する

generic属性内でextendsキーワードを使うと、型パラメータに上界制約(upper bound)を設定できます。

<script setup lang="ts" generic="T extends { id: number; label: string }">
defineProps<{
  options: T[]
  modelValue: T
}>()
</script>

idlabelを持つオブジェクトだけを受け付けるため、テンプレート内でoption.labelに安全にアクセスできます。

プリミティブ型への制約

文字列または数値のみに制限する場合は次のように書きます。

<script setup lang="ts" generic="T extends string | number">
defineProps<{
  value: T
  options: T[]
}>()
</script>

インポートした型をextends先に指定

外部ファイルで定義した型をインポートして制約に利用することも可能です。

<script setup lang="ts" generic="T extends Item">
import type { Item } from './types'

defineProps<{
  data: T[]
}>()
</script>

defineEmitsと組み合わせてイベントにも型を伝搬する

ジェネリクスの型パラメータはdefineEmitsでも参照できます。コンポーネント内部でユーザー操作のイベントを発火するとき、ペイロードの型が自動的に推論されます。

<script setup lang="ts" generic="T extends { id: number }">
const props = defineProps<{
  items: T[]
}>()

const emit = defineEmits<{
  select: [item: T]
  remove: [id: T['id']]
}>()

function handleSelect(item: T) {
  emit('select', item)
}

function handleRemove(item: T) {
  emit('remove', item.id)
}
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <span @click="handleSelect(item)">{{ item.id }}</span>
      <button @click="handleRemove(item)">削除</button>
    </li>
  </ul>
</template>

呼び出し側で@selectを受け取ると、引数の型は渡したデータ型と一致します。

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

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

const users: User[] = [/* ... */]

// item の型は User と推論される
function onSelect(item: User) {
  console.log(item.name)
}
</script>

<template>
  <ItemList :items="users" @select="onSelect" />
</template>

複数の型パラメータを宣言する

カンマ区切りで複数のジェネリック型を定義できます。依存関係のあるProps同士の型を連動させたい場面で活用します。

<script setup lang="ts" generic="TKey extends string, TValue">
defineProps<{
  entries: { key: TKey; value: TValue }[]
  selectedKey: TKey
}>()

defineEmits<{
  change: [key: TKey, value: TValue]
}>()
</script>

テンプレートリテラル型と組み合わせると、CSS カラーパレットのように「色名 → その色のトーン」という依存関係も表現できます。

<script setup lang="ts" generic="T extends 'red' | 'blue' | 'green'">
defineProps<{
  color: T
  shade: `${T}-light` | `${T}-dark`
}>()
</script>

color="red"を指定すると、shadeの候補は"red-light" | "red-dark"に絞り込まれます。

実践パターン:型安全なセレクトボックス

ドロップダウン選択は多くのアプリケーションで登場する汎用UIです。ジェネリクスを使って、選択肢と選択値の型を一致させます。

<!-- GenericSelect.vue -->
<script setup lang="ts" generic="T extends { id: string | number; label: string }">
const props = defineProps<{
  options: T[]
  modelValue: T | null
  placeholder?: string
}>()

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

function handleChange(event: Event) {
  const target = event.target as HTMLSelectElement
  const selected = props.options.find(
    (opt) => String(opt.id) === target.value
  )
  if (selected) {
    emit('update:modelValue', selected)
  }
}
</script>

<template>
  <select
    :value="modelValue?.id ?? ''"
    @change="handleChange"
  >
    <option v-if="placeholder" value="" disabled>
      {{ placeholder }}
    </option>
    <option
      v-for="opt in options"
      :key="opt.id"
      :value="opt.id"
    >
      {{ opt.label }}
    </option>
  </select>
</template>

呼び出し側ではv-modelを使うだけで、選択されたオブジェクト全体が型付きで取得できます。

<script setup lang="ts">
import { ref } from 'vue'
import GenericSelect from './GenericSelect.vue'

interface Prefecture {
  id: number
  label: string
  region: string
}

const prefectures: Prefecture[] = [
  { id: 1, label: '北海道', region: '北海道' },
  { id: 13, label: '東京都', region: '関東' },
  { id: 27, label: '大阪府', region: '近畿' },
]

const selected = ref<Prefecture | null>(null)
</script>

<template>
  <GenericSelect
    v-model="selected"
    :options="prefectures"
    placeholder="都道府県を選択"
  />
  <!-- selected.region に安全にアクセスできる -->
  <p v-if="selected">地方: {{ selected.region }}</p>
</template>

実践パターン:チェック付きデータテーブル

行ごとにチェックボックスを持つテーブルコンポーネントは、管理画面で頻出します。

<!-- GenericTable.vue -->
<script setup lang="ts" generic="T extends Record<string, unknown>">
import { computed } from 'vue'

const props = defineProps<{
  rows: T[]
  columns: { key: keyof T; header: string }[]
}>()

const selectedIds = defineModel<Set<number>>('selectedIds', {
  default: () => new Set<number>(),
})

const emit = defineEmits<{
  rowClick: [row: T]
}>()

const allSelected = computed(() =>
  props.rows.length > 0 &&
  props.rows.every((_, i) => selectedIds.value.has(i))
)

function toggleAll() {
  if (allSelected.value) {
    selectedIds.value = new Set()
  } else {
    selectedIds.value = new Set(props.rows.map((_, i) => i))
  }
}

function toggleRow(index: number) {
  const next = new Set(selectedIds.value)
  if (next.has(index)) {
    next.delete(index)
  } else {
    next.add(index)
  }
  selectedIds.value = next
}
</script>

<template>
  <table>
    <thead>
      <tr>
        <th>
          <input
            type="checkbox"
            :checked="allSelected"
            @change="toggleAll"
          />
        </th>
        <th v-for="col in columns" :key="String(col.key)">
          {{ col.header }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr
        v-for="(row, index) in rows"
        :key="index"
        @click="emit('rowClick', row)"
      >
        <td>
          <input
            type="checkbox"
            :checked="selectedIds.has(index)"
            @change.stop="toggleRow(index)"
          />
        </td>
        <td v-for="col in columns" :key="String(col.key)">
          {{ row[col.key] }}
        </td>
      </tr>
    </tbody>
  </table>
</template>

columnskeykeyof Tで制約しているため、存在しないプロパティ名を指定するとコンパイルエラーになります。

条件付き型と組み合わせた動的Props

ジェネリクスと条件付き型(Conditional Types)を組み合わせると、「特定のPropsが渡されたときだけ別のPropsを必須にする」パターンが実現できます。

<script setup lang="ts" generic="T extends 'view' | 'edit'">
type ModeProps = T extends 'edit'
  ? { mode: T; onSave: () => void }
  : { mode: T; onSave?: never }

defineProps<ModeProps>()
</script>

<template>
  <div>
    <slot />
    <button v-if="mode === 'edit'" @click="onSave">保存</button>
  </div>
</template>

mode="edit"を渡すとonSaveが必須になり、mode="view"ではonSaveを渡せません。Props の組み合わせを型レベルで強制できるため、実行時のバリデーションが不要になります。

Nuxt 3でジェネリクスコンポーネントを使う

Nuxt 3はVue 3をベースにしているため、generic属性はそのまま利用できます。Nuxt特有の注意点を整理します。

auto-importとの併用

Nuxt 3のauto-import機能で自動的にインポートされるコンポーネントでも、generic属性は有効です。components/ディレクトリに配置したジェネリクスコンポーネントをテンプレートから呼び出すと、型推論が正常に動作します。

サーバーコンポーネントでの制限

Nuxt 3のサーバーコンポーネント(.server.vue)はクライアントでハイドレーションされないため、型推論の恩恵を受ける場面は限定されます。型安全性を重視する場合は通常のコンポーネントを使用してください。

必要なバージョン

Nuxt 3.5以降がVue 3.3を同梱しています(出典: Nuxt Blog)。package.jsonvuenuxtのバージョンを確認し、以下を満たすことを確かめてください。

  • Vue: 3.3.0以上
  • Nuxt: 3.5.0以上
  • TypeScript: 5.0以上(推奨)

よくあるエラーと対処法

「Generic type ‘T’ is not assignable」

extends制約で宣言した型と実際に渡したデータの型が一致しないときに発生します。Propsに渡すオブジェクトが制約の条件(必須プロパティなど)を満たしているか確認してください。

IDEで型補完が効かない

VS Codeで型補完が動作しない場合、以下を確認してください。

  • Vue - Official(旧Volar)拡張機能がインストール済みか
  • Vetur拡張機能が無効化されているか(VolarとVeturは併用できません)
  • vue-tscのバージョンがVue本体のバージョンと一致しているか

テンプレートref で型を取得できない

ジェネリクスコンポーネントのインスタンス型を取得するには、InstanceTypeではなくvue-component-type-helpersライブラリのComponentExposedを使います。

import type { ComponentExposed } from 'vue-component-type-helpers'
import GenericSelect from './GenericSelect.vue'

type SelectInstance = ComponentExposed<typeof GenericSelect>

@vue-genericディレクティブによる明示的な型指定

型を推論できないケース(Propsに直接リテラルを渡さない場合など)では、テンプレート内でコメントを使って型を明示できます。

<template>
  <!-- @vue-generic {Product} -->
  <GenericSelect :options="computedOptions" />
</template>

defineSlots(Vue 3.3+)との併用

Vue 3.3で追加されたdefineSlotsとジェネリクスを組み合わせると、スロットに渡されるデータの型も制御できます。

<script setup lang="ts" generic="T extends { id: number }">
defineProps<{
  items: T[]
}>()

defineSlots<{
  default: (props: { item: T; index: number }) => void
}>()
</script>

<template>
  <div v-for="(item, index) in items" :key="item.id">
    <slot :item="item" :index="index" />
  </div>
</template>

呼び出し側でスコープドスロットの変数にアクセスすると、itemの型が自動的にデータ型と一致します。

<template>
  <GenericList :items="users">
    <template #default="{ item }">
      <!-- item  User 型として補完される -->
      <span>{{ item.name }} ({{ item.email }})</span>
    </template>
  </GenericList>
</template>

ジェネリクスを使うべき場面と避けるべき場面

すべてのコンポーネントにジェネリクスが適しているわけではありません。

ジェネリクスが有効なケース:

  • 複数のデータ型を受け取る汎用コンポーネント(テーブル、セレクト、リストなど)
  • イベントペイロードにデータ型を正確に伝搬させたいとき
  • Props間の型の整合性を保証したいとき

ジェネリクスが不要なケース:

  • 特定のデータ型しか受け取らないコンポーネント
  • Propsがプリミティブ値のみのコンポーネント(ボタン、アイコンなど)
  • ロジックを持たない純粋な表示コンポーネント

シンプルなコンポーネントに無理にジェネリクスを導入すると、型定義が煩雑になり可読性が下がります。汎用性と複雑性のバランスを見極めることが重要です。

まとめ

Vue 3.3のgeneric属性により、SFCでもTypeScriptのジェネリクスが利用可能になりました。definePropsdefineEmitsdefineSlotsのすべてで型パラメータを参照でき、コンポーネントの入出力を型レベルで一貫させられます。

extends制約や条件付き型との組み合わせにより、従来は実行時バリデーションに頼っていたProps の制約をコンパイル時に検出できます。セレクトボックスやデータテーブルなど、汎用UIコンポーネントの型安全性を大幅に向上させる機能として、積極的に活用してみてください。