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>
itemsをunknown[]で受け取ると、呼び出し側で@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>
itemsにProduct[]を渡した時点で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>
idとlabelを持つオブジェクトだけを受け付けるため、テンプレート内で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>
columnsのkeyをkeyof 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.jsonのvueとnuxtのバージョンを確認し、以下を満たすことを確かめてください。
- 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のジェネリクスが利用可能になりました。defineProps・defineEmits・defineSlotsのすべてで型パラメータを参照でき、コンポーネントの入出力を型レベルで一貫させられます。
extends制約や条件付き型との組み合わせにより、従来は実行時バリデーションに頼っていたProps の制約をコンパイル時に検出できます。セレクトボックスやデータテーブルなど、汎用UIコンポーネントの型安全性を大幅に向上させる機能として、積極的に活用してみてください。
