2026年のVue 3とTypeScript: 型安全なProps・Emits・コンポーザブル

TypeScriptで型安全なVue 3コンポーネントを習得: ジェネリックdefineProps、タプル形式のdefineEmits、型付きコンポーザブル、defineModel、InjectionKey、面接質問まで解説。

Vue 3 TypeScriptの型安全なprops・emits・コンポーザブル

2026年のVue 3とTypeScriptの組み合わせは、props・イベント・再利用ロジックのすべてに静的な型チェックをもたらします。<script setup> 構文と definePropsdefineEmits といったコンパイラマクロを併用することで、かつては実行時のオブジェクト定義でしかなかったものが、コンパイル時の契約へと変わります。本ガイドでは、実務コードと技術面接の両方でVue開発者に求められる型安全なパターンを解説します。

型安全なpropsを一行で

Vue 3.5以降では、ジェネリック版の defineProps<T>() がTypeScriptのインターフェースから直接prop型を推論します。実行時の type フィールドも PropType によるキャストも不要です。リアクティブなprops分割代入と組み合わせれば、デフォルト値もインラインで記述できます: const { size = 'md' } = defineProps<Props>()

definePropsとTypeScriptインターフェースでpropsに型を付ける

実行時のprop宣言(props: { title: String })も動作しますが、型情報が重複し、精度が失われます。ユニオン型・ネストしたオブジェクト・関数シグネチャをきれいに表現することはできません。ジェネリック形式の defineProps は、TypeScriptの型から形状を受け取り、Vueコンパイラがそれを正しい実行時宣言へ自動的に変換することでこの問題を解決します。

vue
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  userId: number            // required primitive
  name: string
  role: 'admin' | 'member'  // union type, impossible with runtime props
  tags?: string[]           // optional array
  onSelect?: (id: number) => void  // typed callback prop
}

const props = defineProps<Props>()
</script>

<template>
  <article @click="props.onSelect?.(props.userId)">
    <h3>{{ props.name }}</h3>
    <span>{{ props.role }}</span>
  </article>
</template>

role propは2つのリテラル文字列に制約されるため、role="guest" を渡すとビルドが失敗します。これこそがTypeScriptと defineProps を使う中心的な理由です。Vue公式のTypeScriptガイドでも、<script setup> プロジェクトではジェネリック形式が既定として扱われています。

propのデフォルト値: withDefaults対リアクティブ分割代入

型のみのpropsには、それ単体では実行時のデフォルト値メカニズムがありません。従来の答えは withDefaults で、マクロをラップしてデフォルト値オブジェクトをマージします。

vue
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
})
</script>

Vue 3.5ではリアクティブなprops分割代入が安定版となり、こちらがより簡潔なパターンになりました。defineProps の結果を分割代入し、同じ文の中でデフォルト値を割り当てても、完全にリアクティブなまま保たれます。コンパイラが内部で各アクセスを props.x へ書き換えるためです。

vue
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
  label: string
  color?: 'green' | 'red'
  outlined?: boolean
}

// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>

<template>
  <span :class="[color, { outlined }]">{{ label }}</span>
</template>
分割代入したpropsのリアクティビティの罠

分割代入したpropsはテンプレート内や computed の中ではリアクティブなまま保たれますが、分割代入した値をそのまま watch やコンポーザブルに渡すと一度だけ読み取られ、リアクティブなつながりが失われます。ゲッターで包む(watch(() => color, ...))か、下流でrefが必要な場合は toRef(props, 'color') で変換してください。

これは面接でよくある罠です。候補者は分割代入した変数を単なる値だと思い込みますが、実際にはコンパイラがすべての読み取りを props.color へ配線し直しています。

defineEmitsによる型安全なemits

イベントもpropsと同じ厳密さに値します。ジェネリック版の defineEmits は、各イベント名とそのペイロードをタプルとして記述し、親コンポーネントには補完を、子コンポーネントには正しい引数がemitされているかのコンパイル時チェックを提供します。

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
  search: [query: string]
  clear: []                       // no payload
  select: [id: number, label: string]  // multiple args
}>()

function onSubmit(value: string) {
  emit('search', value)   // ✅ typed
  // emit('search', 42)   // ❌ compile error: number not assignable to string
}
</script>

このタプル形式は、古い呼び出しシグネチャ構文((e: 'search', q: string): void)を置き換えるものです。読みやすく、オーバーロードなしで複数のイベントに対応できるためです。イベントがデータを持たない場合、空のタプル [] がそれを明示的に文書化します。型付きのemitsと型付きのpropsを組み合わせることで、コンポーネントの公開インターフェース全体が実行前に検証可能になります。これはVue Composition APIガイドで扱っている規律と同じものです。

再利用ロジックのためのコンポーザブルに型を付ける

コンポーザブルは単なる関数なので、通常のTypeScriptルールに従います。ただし、いくつかの慣習を守ると扱いやすさが保たれます。推論が自明でない場合は Ref 型を明示的に返し、APIレスポンスのような任意のデータをラップする場合はジェネリックを使いましょう。

useFetch.tstypescript
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
}

// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(true)

  fetch(url)
    .then((r) => r.json())
    .then((json: T) => { data.value = json })
    .catch((e: Error) => { error.value = e })
    .finally(() => { loading.value = false })

  return { data, error, loading }
}

呼び出し側では、ジェネリックパラメータによって data が追加の注釈なしに完全に型付けされます。

UserList.vue (script setup)typescript
interface User { id: number; name: string }

// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')

戻り値オブジェクトに明示的に型注釈を付けること(UseFetchReturn<T>)は、数行余分に書く価値があります。契約を文書化し、内部refの意図しない漏洩を防ぎ、利用者にインポートすべき単一の型を提供するからです。引数のオーバーロードやライフサイクルに応じたクリーンアップなど、より深いコンポーザブルパターンについては上級Vueコンポーザブルガイドを参照してください。

Vue.js / Nuxt.jsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

双方向バインディングのためのdefineModelに型を付ける

Vue 3.4以降で安定版となった defineModel は、従来の modelValue propと update:modelValue emitを、単一の書き込み可能なrefにまとめます。ジェネリックパラメータがバインディングの両方向を一度に型付けします。

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })

function increment() {
  model.value++   // writing back updates the parent
}
</script>

<template>
  <input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
  <button @click="increment">+1</button>
</template>

名前付きモデル(defineModel<string>('title'))は v-model:title に対応し、同じ型付けを受け取ります。これにより、手動のprop・emitパターンが隠していたペイロード不一致のバグ群を丸ごと排除できます。

テンプレートrefとコンポーネントインスタンスに型を付ける

ref を通じてDOMノードや子コンポーネントにアクセスする箇所こそ、型のないVueコードが最も頻繁に any へ後退する場所です。解決策は、useTemplateRef(Vue 3.5以降)や ref 自体を要素型でパラメータ化することです。そうすればプロパティアクセスが実際のDOMインターフェースに対してチェックされます。

vue
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')

onMounted(() => {
  // .focus() and .select() are known because the element type is explicit
  inputRef.value?.focus()
})
</script>

<template>
  <input ref="field" placeholder="Type to search" />
</template>

子コンポーネントへの参照には、InstanceType<typeof Child> がコンポーネントの公開型を抽出し、子が defineExpose で宣言した内容を公開します。これにより、親から子へのメソッド呼び出しが推測ではなく完全にチェックされた状態に保たれます。

vue
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'

// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')

function launch() {
  modal.value?.open()   // ✅ checked against Modal's defineExpose
}
</script>

InjectionKeyによる型安全なprovideとinject

コンポーネントツリーをまたぐ依存性注入は、キーが型情報を運ばない限り型情報を失います。InjectionKey<T> は値の型をそのキーに結び付ける型付きのシンボルであり、手動キャストなしに provideinject の同期を保ちます。

theme-key.tstypescript
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
  mode: Ref<'light' | 'dark'>
  toggle: () => void
}

// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
typescript
// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })

// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()

ThemeContext に一致しない形状の値を提供すると、注入箇所でコンパイルエラーになり、離れたコンポーネント間のズレが出荷前に捕捉されます。

VueのTypeScriptに関するよくある面接質問

面接官は、コンパイル時の型と実行時の挙動の境界を候補者が理解しているかを探ります。よく出る質問とその的確な回答をいくつか挙げます。

  • なぜ実行時オブジェクト形式より defineProps<T>() を選ぶのか? ジェネリックpropsはユニオン型・関数シグネチャ・ネストした形状を表現でき、これらは実行時宣言では不可能です。また型と実行時定義の重複を取り除けます。
  • 分割代入したpropsはリアクティブか? Vue 3.5以降ではリアクティブです。コンパイラがアクセスを props.x に書き換えるためです。ただし watch やコンポーザブルに渡した分割代入済みの値は一度だけ読み取られます。ゲッターか toRef を使いましょう。
  • ペイロードのないemitイベントにどう型を付けるか? 空のタプルです: defineEmits<{ close: [] }>()
  • defineModel は何を置き換えるのか? modelValue propと update:modelValue emitのペアを、単一の型付き書き込み可能refにまとめます。

これらを実際の問題集で練習すると、面接が評価する反射神経が磨かれます。Vueコンポーザブル面接モジュールはまさにこれらのパターンを徹底演習します。ツールも重要です。CIで vue-tsc を実行して型エラーがマージを止めるようにし、コンポーザブルのジェネリックが絡むときはTypeScriptハンドブックを頼りにしましょう。エディタ体験はVue公式のVolarツールによって支えられており、これらのマクロを読み取ってエラーをインラインで表示します。

まとめ

  • 実行時宣言では捉えられないユニオン型・省略可能フィールド・コールバックpropを表現するために、ジェネリック版 defineProps<Props>() を使いましょう。
  • Vue 3.5以降ではインラインのデフォルト値付きリアクティブprops分割代入を優先し、共有デフォルト値オブジェクトのほうが明確な場合にのみ withDefaults を使いましょう。
  • 子と親の両方でペイロードがコンパイル時にチェックされるよう、タプル形式の defineEmits でイベントに型を付けましょう。
  • コンポーザブルの戻り値型を明示的に注釈し、ジェネリックで呼び出し側が指定したデータ型を端から端まで受け渡しましょう。
  • 双方向バインディングには defineModel<T>() を採用し、prop・emitの定型句を単一の型付きrefにまとめましょう。
  • 型のリグレッションが本番に届く前にビルドを失敗させるため、継続的インテグレーションで vue-tsc を実行しましょう。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

共有

関連記事