TypeScript with Composition API - 推論中心の型設計

ロードマップ: Vue.js学習ロードマップ

What(何についてか)

Composition API と <script setup> を前提に、Vue 3 で TypeScript を実用的に使うための型付けパターンを整理する。対象は props / emits / reactivity API / DOM イベント / provide-inject / template refs / composable まで含む。

Why(なぜ必要か)

Composition API では ref, computed, composable, props, emits など多様な境界が存在する。すべてに過剰な型注釈を書くのではなく、Vue と TypeScript の推論を活かしつつ、契約が重要な箇所だけを明示的に型付けする方が読みやすく保守しやすい。

How(どう動くか)

1. Props は defineProps<Props>() を基準に考える

<script setup> では props を runtime declaration と type-based declaration のどちらでも書けるが、TypeScript 前提では type-based declaration の方が見通しが良い。

<script setup lang="ts">
interface Props {
  foo: string
  bar?: number
}
 
const props = defineProps<Props>()
</script>

runtime declaration:

<script setup lang="ts">
const props = defineProps({
  foo: { type: String, required: true },
  bar: Number
})
</script>

両者は併用できない。型ベース宣言では compiler が可能な範囲で runtime props 設定へ変換する。

デフォルト値

型ベース宣言で optional props にデフォルト値を付ける方法は主に2つある。

interface Props {
  msg?: string
  labels?: string[]
}
 
const { msg = 'hello', labels = ['one', 'two'] } = defineProps<Props>()

または:

const props = withDefaults(defineProps<Props>(), {
  msg: 'hello',
  labels: () => ['one', 'two']
})

配列やオブジェクトのような可変参照型は withDefaults では関数で包み、インスタンス間共有を避ける。

複雑な props 型

複雑なオブジェクト型も type-based declaration なら普通の TypeScript 型として記述できる。

<script setup lang="ts">
interface Book {
  title: string
  author: string
  year: number
}
 
const props = defineProps<{ book: Book }>()
</script>

runtime declaration で複雑型を扱う場合は PropType<T> を使う。

2. Emits は出力契約として型で固定する

defineEmits はイベント名と payload の契約を定義する。

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

この形にすると、イベント名の typo や payload 型不一致をコンパイル時に検出できる。props が入力契約なら emits は出力契約である。

3. ref() は初期値からの推論を基本にする

ref() は初期値から自然に型推論される。

const year = ref(2020) // Ref<number>

必要ならジェネリクスや Ref<T> で明示できる。

const year = ref<string | number>('2020')
const n = ref<number>() // Ref<number | undefined>

初期値なしの ref<T>()undefined を含む点に注意する。

4. reactive() は推論を優先する

const book = reactive({ title: 'Vue 3 Guide' })

reactive() はネストした ref の unwrap など Vue 固有の変換を行うため、reactive<T>() とジェネリクスで型を先に固定するより、推論を優先した方が実態と整合しやすい。

interface Book {
  title: string
  year?: number
}
 
const book: Book = reactive({ title: 'Vue 3 Guide' })

5. computed() は getter の戻り値から型が決まる

const count = ref(0)
const double = computed(() => count.value * 2) // ComputedRef<number>

必要なら computed<number>() のように明示できるが、通常は推論で十分である。

6. DOM イベントでは event 型と target 型を明示する

ネイティブ DOM イベントは Vue ではなくブラウザ API の型に従う。

function handleChange(event: Event) {
  console.log((event.target as HTMLInputElement).value)
}

event.target は抽象的な EventTarget なので、HTMLInputElement などの具体型への絞り込みが必要になる。

7. Provide / Inject は InjectionKey<T> で同期する

import { provide, inject } from 'vue'
import type { InjectionKey } from 'vue'
 
const key = Symbol() as InjectionKey<string>
 
provide(key, 'foo')
const foo = inject(key) // string | undefined

InjectionKey<T> により、provide 側と inject 側が同じ型契約を共有できる。キーは共通ファイルへ切り出すのが定石である。

flowchart LR
  A["InjectionKey<T>"] --> B["provide(key, value)"]
  A --> C["inject(key)"]
  B --> D["型チェックされた提供値"]
  C --> E["T | undefined"]

8. Template Refs は null とライフサイクルを前提に型付けする

DOM 要素への template ref はマウント前や v-if によるアンマウント時に null を取りうる。

const el = ref<HTMLInputElement | null>(null)

使用時は optional chaining や型ガードが必要になる。

el.value?.focus()

コンポーネント ref では InstanceType<typeof Foo> を使って子コンポーネントの公開 API を型付けできる。

9. Composable は通常の .ts モジュールとして型付けできる

Composable は状態を持つ再利用関数であり、TypeScript では通常のモジュールとして扱う。

// mouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
 
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
 
  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }
 
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
 
  return { x, y }
}
<script setup lang="ts">
import { useMouse } from './mouse'
 
const { x, y } = useMouse()
</script>

import は通常拡張子なしで書き、モジュール解決は TypeScript とビルドツールに任せる。

Options API → Composition API 差分(補足)

項目Options API(旧)Composition API(新)
Props 型付けdefineComponent + props オプション前提defineProps<Props>() が中心
Emits 型付けemits オプション中心defineEmits<...>() でイベント契約を直接書ける
State 型付けdata()this の関係が重要ref, reactive, computed の推論が中心
再利用mixins などの旧来手法が混在しやすいcomposable を .ts 関数として管理しやすい
DOM / refthis.$refs 的な扱いに寄りやすい`ref<T

Key Concepts

用語説明
defineProps<Props>()TypeScript 型を基準に props 契約を宣言する方法
withDefaultstype-based props にデフォルト値を付ける compiler macro
defineEmits<...>()イベント名と引数の契約を型で表現する方法
Ref<T>ref() が返すリアクティブ参照型
reactive()オブジェクト全体をリアクティブ化する API。推論優先が基本
ComputedRef<T>computed() による派生値の参照型
InjectionKey<T>provide / inject の値型を同期するための型付きキー
template refDOM 要素や子コンポーネントへの参照。null を含むライフサイクル依存値
composable状態や副作用を再利用するための関数モジュール