- Vue.js
Vue TypeScriptの型の書き方|props・emit・ref早見表
VueでTypeScriptの型を書くとは、script setup に lang="ts" を付け、defineProps と defineEmits には型引数を渡し、ref や computed は初期値からの推論に任せる(推論できないときだけ型引数を足す)ことです。 型が正しく書けているかどうかは、エディターの表示に加えて vue-tsc というコマンドで一括チェックできます。
この記事では、props・emit・ref・computed・テンプレート参照・DOMイベントの型を「書き方」と「推論される型」の対応表にまとめました。表の右列はすべて vue-tsc に実際に表示させた型です。後半では、型が合わないときに出るエラー文(TS2322 など)を7パターン再現し、直す前と後を並べています。Vue 3.5 の分割代入デフォルト値と、generic 属性を使ったジェネリックコンポーネントも扱います。
確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / vue-tsc 3.3.12 / TypeScript 6.0.3 / Vitest 5.0.3 / @vue/test-utils 2.5.1 / jsdom 30.1.1
Vue TypeScriptの型の早見表
次の表は「何を書くと、どんな型になるか」の一覧です。右列は vue-tsc 3.3.12 が表示した型をそのまま載せています。
| やりたいこと | 書き方 | 推論される型 |
|---|---|---|
| props を型で宣言 | defineProps<{ title: string; count?: number }>() |
props.title は string、props.count は number | undefined |
| props のデフォルト値(3.5以降) | const { size = 'md' } = defineProps<{ size?: 'sm' | 'md' }>() |
size は 'sm' | 'md'(undefined が外れる) |
| props のデフォルト値(3.4まで) | withDefaults(defineProps<{ a?: number }>(), { a: 1 }) |
props.a は number |
| emit を型で宣言(3.3以降) | defineEmits<{ change: [id: number] }>() |
(evt: "change", id: number) => void |
| emit を型で宣言(従来の記法) | defineEmits<{ (e: 'change', id: number): void }>() |
(e: "change", id: number): void |
| 数値や文字列の ref | ref(0) |
Ref<number, number> |
| null を含む ref | ref<string | null>(null) |
Ref<string | null, string | null> |
| 初期値なしの ref | ref<number>() |
Ref<number | undefined, number | undefined> |
| 配列の ref | ref<Item[]>([]) |
.value は Item[] |
| computed | computed(() => n.value * 2) |
ComputedRef<number> |
| 書き込みできる computed | computed({ get, set }) |
WritableComputedRef<string, string> |
| reactive | reactive({ page: 1, q: '' }) |
{ page: number; q: string } |
| DOM要素の参照(3.5以降) | useTemplateRef<HTMLInputElement>('inp') |
Readonly<ShallowRef<HTMLInputElement | null>> |
| 子コンポーネントの参照 | useTemplateRef<InstanceType<typeof TaskCard>>('card') |
子のインスタンス型 | null |
| イベントハンドラーの引数 | function onInput(e: Event) |
e.target は EventTarget | null |
| ジェネリックコンポーネント | script タグに generic="T extends { id: number }" |
props の items は T[] |
| v-model 用の値(3.4以降) | defineModel<string>() |
ModelRef<string | undefined, string, …> |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue TypeScriptの型の書き方|props・emit・ref早見表」
準備:vue-tscで型チェックできる状態にする
インストールとtsconfig.json
Vue の単一ファイルコンポーネント(拡張子 .vue のファイル。以下 SFC)は、TypeScript 標準の tsc コマンドだけでは中身を読めません。SFC の型チェックには vue-tsc を使います。vue-tsc は tsc を包んだコマンドで、オプションは tsc と同じものが使えます。
npm i vue
npm i -D typescript@6 vue-tsc
tsconfig.json は次の内容で動きます。strict: true を入れておくと、null や undefined の扱いまでチェックされます。
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"],
"types": []
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
実行コマンドはこれだけです。エラーがなければ何も表示されず、終了コード 0 で終わります。
npx vue-tsc --noEmit -p tsconfig.json
tscとvue-tscの違いを実行結果で見る
src/main.ts に import App from './App.vue' と書いた状態で、tsc と vue-tsc を順に走らせた結果です。
$ npx tsc --noEmit -p tsconfig.json
src/main.ts(1,17): error TS2307: Cannot find module './App.vue' or its corresponding type declarations.
$ npx vue-tsc --noEmit -p tsconfig.json
(出力なし・終了コード 0)
tsc は .vue を解決できず TS2307 になり、vue-tsc は SFC の中まで読んで通します。 package.json の scripts には "typecheck": "vue-tsc --noEmit" のように登録しておくと、ビルド前に一括で確認できます。
TypeScriptの版とvue-tscの組み合わせ
2026年10月時点で npm i typescript とすると 7.0.2 が入ります。この組み合わせで vue-tsc 3.3.12 を起動すると、次の表示で止まりました。
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/tsc' is not defined by "exports" in .../node_modules/typescript/package.json
npm i -D typescript@6 で 6.0.3 を入れ直すと、同じ vue-tsc がそのまま動きました。vue-tsc 3.3.12 を使うときは TypeScript 6 系を指定する、と覚えておくとスムーズです。なお npx vue-tsc --version は、中で使っている TypeScript の版(今回は Version 6.0.3)を表示します。
コピペで動く完成形:3つのSFCで型を一通り使う
タスク一覧を題材に、この記事で扱う型をすべて使った3ファイルです。src フォルダに置けば、そのまま vue-tsc が通ります。
子コンポーネント(props・emit・computed・イベント)
<!-- src/TaskCard.vue -->
<script setup lang="ts">
import { computed } from 'vue'
type Task = { id: number; title: string; done: boolean }
// Vue 3.5: 分割代入の = がそのままデフォルト値になる
const { task, size = 'md', tags = [] } = defineProps<{
task: Task
size?: 'sm' | 'md' | 'lg'
tags?: string[]
}>()
// Vue 3.3 以降の名前付きタプル記法
const emit = defineEmits<{
toggle: [id: number]
rename: [id: number, title: string]
}>()
const label = computed(() => `${task.done ? '済' : '未'} ${task.title} (${size})`)
function onInput(e: Event) {
const input = e.target as HTMLInputElement
emit('rename', task.id, input.value)
}
</script>
<template>
<div class="card">
<span>{{ label }}</span>
<span v-for="t in tags" :key="t">#{{ t }}</span>
<button @click="emit('toggle', task.id)">切り替え</button>
<input :value="task.title" @input="onInput" />
</div>
</template>
ジェネリックコンポーネント(generic属性)
<!-- src/SelectList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ items: T[]; selected?: T }>()
const emit = defineEmits<{ select: [item: T] }>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id" @click="emit('select', item)">
<slot :item="item">{{ item.id }}</slot>
</li>
</ul>
</template>
親コンポーネント(ref・テンプレート参照・キーボードイベント)
<!-- src/App.vue -->
<script setup lang="ts">
import { ref, computed, useTemplateRef, onMounted } from 'vue'
import TaskCard from './TaskCard.vue'
import SelectList from './SelectList.vue'
type Task = { id: number; title: string; done: boolean }
const tasks = ref<Task[]>([
{ id: 1, title: '見積もりを送る', done: false },
{ id: 2, title: '備品を発注する', done: true },
])
const picked = ref<Task | null>(null)
const doneCount = computed(() => tasks.value.filter((t) => t.done).length)
const search = useTemplateRef<HTMLInputElement>('search')
const card = useTemplateRef<InstanceType<typeof TaskCard>>('card')
onMounted(() => search.value?.focus())
function toggle(id: number) {
const t = tasks.value.find((t) => t.id === id)
if (t) t.done = !t.done
}
function rename(id: number, title: string) {
const t = tasks.value.find((t) => t.id === id)
if (t) t.title = title
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') picked.value = null
}
</script>
<template>
<input ref="search" @keydown="onKey" />
<p>完了 {{ doneCount }} / {{ tasks.length }}</p>
<TaskCard ref="card" :task="tasks[0]!" :tags="['急ぎ']" @toggle="toggle" @rename="rename" />
<SelectList :items="tasks" @select="(t) => (picked = t)">
<template #default="{ item }">{{ item.title }}</template>
</SelectList>
<p>選択中: {{ picked?.title ?? 'なし' }}</p>
</template>
実行結果
vue-tsc は出力なし(終了コード 0)で通りました。続けて Vitest と jsdom で App.vue をマウントし、最初の HTML と、「切り替え」ボタン・2件目のリスト項目をクリックしたあとの表示を出力した結果です。
<input>
<p>完了 1 / 2</p>
<div class="card"><span>未 見積もりを送る (md)</span><span>#急ぎ</span><button>切り替え</button><input value="見積もりを送る"></div>
<ul>
<li>見積もりを送る</li>
<li>備品を発注する</li>
</ul>
<p>選択中: なし</p>
完了 2 / 2 | 選択中: 備品を発注する
済 見積もりを送る (md) | focus: INPUT
size を渡していないのに (md) と表示されているので、分割代入のデフォルト値が効いています。SelectList の select イベントで受け取った t は、型注釈を書かなくても Task として推論され、picked.value に代入できています。
definePropsの型の書き方
型引数で宣言する(基本)
defineProps には、props の形をそのまま型引数で渡します。 ? を付けた項目が省略可能な props になります。
const props = defineProps<{
title: string // 必須
count?: number // 省略可能 → number | undefined
items: Item[] // 配列
size?: 'sm' | 'md' // 決まった文字列だけ
}>()
型は同じファイルの type や interface だけでなく、別ファイルから import した型も使えます。公式ドキュメントでは、import した型を使う機能は TypeScript が Vue の peer dependency としてインストールされていることが条件と説明されています。
デフォルト値を付ける:Vue 3.5の分割代入
Vue 3.5 からは、defineProps の戻り値を分割代入し、= で書いた値がそのままデフォルト値になります。型も undefined が外れた形で推論されます。
const { size = 'md', tags = [] } = defineProps<{
size?: 'sm' | 'md'
tags?: string[]
}>()
| 変数 | vue-tsc が表示した型 |
|---|---|
size |
"sm" | "md" |
tags |
string[] |
配列やオブジェクトのデフォルト値も = [] と直接書けます。分割代入した変数はリアクティブなまま使えるので、テンプレートや computed の中で普通に参照できます。
分割代入したpropsをwatchに渡すとき
分割代入した変数を watch の第1引数にそのまま渡すと、型チェックとビルドの両方で知らせてくれます。
const { size = 'md' } = defineProps<{ size?: 'sm' | 'md' }>()
watch(size, (v) => console.log(v))
vue-tsc の表示:
src/fix/W.vue(4,7): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'string' is not assignable to parameter of type 'object'.
Type 'string' is not assignable to type 'object'.
Vite(@vitejs/plugin-vue)で読み込んだときの表示:
[@vue/compiler-sfc] "size" is a destructured prop and should not be passed directly to watch(). Pass a getter () => size instead.
メッセージのとおり、関数で包んで () => size を渡します。次のコードは size を sm から md に変えたときに、ログが1件記録されました。
<script setup lang="ts">
import { watch, ref } from 'vue'
const { size = 'md' } = defineProps<{ size?: 'sm' | 'md' }>()
const log = ref<string[]>([])
watch(() => size, (v, old) => log.value.push(`${old} -> ${v}`))
</script>
<template><p>{{ size }} / {{ log.join(', ') }}</p></template>
<p>md / sm -> md</p>
Vue 3.4までの書き方:withDefaults
Vue 3.4 以前では withDefaults を使います。3.5 でも引き続き動きます。配列やオブジェクトのデフォルト値は関数で包みます。
const props = withDefaults(
defineProps<{ a?: number; tags?: string[] }>(),
{ a: 1, tags: () => [] },
)
// props.a は number、props.tags は string[]
ランタイム宣言(オブジェクトで書く形)の型
型引数ではなくオブジェクトで宣言する書き方でも、型は推論されます。オブジェクトや配列の中身まで型を付けたいときは PropType を使います。
import type { PropType } from 'vue'
type Task = { id: number; title: string }
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
task: { type: Object as PropType<Task>, required: true },
memo: String,
})
| プロパティ | vue-tsc が表示した型 |
|---|---|
props.title |
string |
props.count |
number |
props.task |
Task |
props.memo |
string | undefined |
どの書き方を選ぶかの判断表
| 状況 | 選ぶ書き方 |
|---|---|
| Vue 3.5 以降で新しく書く | 型引数 + 分割代入のデフォルト値 |
| Vue 3.3〜3.4 | 型引数 + withDefaults |
| 実行時のバリデーション(validator)も使いたい | ランタイム宣言 + PropType |
| JavaScript のコンポーネントと書き方をそろえたい | ランタイム宣言 |
defineEmitsの型の書き方
Vue 3.3以降の名前付きタプル記法
イベント名をキーに、引数を名前付きタプルで書くのが 3.3 以降の簡潔な記法です。引数がないイベントは空の配列にします。
const emit = defineEmits<{
change: [id: number]
update: [value: string, at: Date]
close: []
}>()
emit('change', 1)
emit('update', 'abc', new Date())
emit('close')
change と update の2つを宣言したときに vue-tsc が表示した emit の型は次のとおりです。
((evt: "change", id: number) => void) & ((evt: "update", value: string, at: Date) => void)
従来の関数シグネチャ記法との対応
3.3 より前から使える書き方も、引き続き有効です。
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
| 項目 | 名前付きタプル記法(3.3以降) | 関数シグネチャ記法 |
|---|---|---|
| 1イベントの書き方 | change: [id: number] |
(e: 'change', id: number): void |
| 引数なし | close: [] |
(e: 'close'): void |
| 表示された emit の型 | (evt: "change", id: number) => void の交差型 |
{ (e: "change", id: number): void; … } |
| 配列だけで宣言した場合 | ― | defineEmits(['close']) は (event: "close", ...args: any[]) => void |
配列だけの宣言(defineEmits(['close']))は引数が any になるので、引数の型まで確認したいときは型引数で書くのが向いています。
親側のハンドラーも型チェックされる
子で toggle: [id: number] と宣言しておくと、親が @toggle に渡す関数の引数もチェックされます。インラインのアロー関数なら、引数の型注釈を書かなくても推論されます(完成形コードの @select="(t) => (picked = t)" がその例です)。
ref・computed・reactiveの型
refは初期値から推論、足りないときだけ型引数
| 書き方 | vue-tsc が表示した型 | 使いどころ |
|---|---|---|
ref(0) |
Ref<number, number> |
初期値で型が決まる場合 |
ref<string | null>(null) |
Ref<string | null, string | null> |
あとから値が入る場合 |
ref<number>() |
Ref<number | undefined, number | undefined> |
初期値を省く場合 |
ref<Item[]>([]) |
Ref<{ id: number; name: string }[], …> |
空配列から始める場合 |
shallowRef<Item | null>(null) |
ShallowRef<Item | null, Item | null> |
中身の変更を追わない場合 |
空配列 ref([]) や ref(null) は、初期値だけでは中身の型が決まらないので型引数を付けます。 Ref の2つ目の型引数は「代入するときに受け付ける型」で、通常は1つ目と同じになります。
computedは戻り値から推論
const n = ref(0)
const double = computed(() => n.value * 2) // ComputedRef<number>
const text = computed({
get: () => String(n.value),
set: (v: string) => { n.value = Number(v) },
}) // WritableComputedRef<string, string>
戻り値の型を固定したいときは computed<number>(() => …) と型引数を付けます。関数の中身を書き換えたときに、意図しない型になっていないかをその場で確認できます。
reactiveはオブジェクトの形がそのまま型になる
const state = reactive({ page: 1, q: '' }) // { page: number; q: string }
型を明示するときは、変数側に注釈を付ける書き方が使えます(vue-tsc で通ることを確認しています)。
type State = { page: number; q: string }
const state: State = reactive({ page: 1, q: '' })
テンプレート参照の型
DOM要素:useTemplateRef
Vue 3.5 で入った useTemplateRef は、template 側の ref="名前" と文字列で結び付けます。
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
const inp = useTemplateRef<HTMLInputElement>('inp')
onMounted(() => inp.value?.focus())
</script>
<template><input ref="inp" /></template>
| 書き方 | vue-tsc が表示した型 |
|---|---|
useTemplateRef<HTMLInputElement>('inp') |
Readonly<ShallowRef<HTMLInputElement | null>> |
useTemplateRef('inp')(型引数なし、template に input の ref あり) |
Readonly<ShallowRef<HTMLInputElement | null, HTMLInputElement | null>> |
ref<HTMLInputElement>()(3.4までの書き方) |
Ref<HTMLInputElement | undefined, HTMLInputElement | undefined> |
型引数を省いても、同じ SFC の template に静的な ref があれば要素の型が自動で推論されました。 公式ドキュメントでは、この自動推論は Vue 3.5 と @vue/language-tools 2.1 以降の機能と説明されています。どちらの書き方でも、マウント前は null なので ?. で触るのが基本です。
子コンポーネント:InstanceType
子コンポーネントを参照するときは、InstanceType<typeof コンポーネント> で型を取り出します。
import TaskCard from './TaskCard.vue'
const card = useTemplateRef<InstanceType<typeof TaskCard>>('card')
ジェネリックコンポーネントの参照については、公式ドキュメントで vue-component-type-helpers パッケージの ComponentExposed を使う方法が案内されています。
DOMイベントの型
ハンドラーを関数で書くときは引数に型を付ける
script 側に関数として書くハンドラーは、引数に Event などの型を自分で付けます。付けないと strict の設定では暗黙の any として扱われます。
function onInput(e: Event) {
const input = e.target as HTMLInputElement
console.log(input.value)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') console.log('決定')
}
テンプレートのインライン関数は自動で推論される
template に直接アロー関数を書いた場合は、イベント名から型が決まります。次の表は、各イベントでインライン関数の引数 e がどの型になったかを vue-tsc に表示させた結果です。
| 書いたイベント | 要素 | 推論された型 |
|---|---|---|
@input |
input |
InputEvent |
@change |
input |
Event |
@click |
input |
PointerEvent |
@keydown |
input |
KeyboardEvent |
@focus |
input |
FocusEvent |
@submit.prevent |
form |
SubmitEvent |
@dragstart |
div |
DragEvent |
@wheel |
div |
WheelEvent |
@pointerdown |
div |
PointerEvent |
click が PointerEvent になっているのは、TypeScript 6.0.3 に同梱の DOM 型定義によるものです。PointerEvent は MouseEvent を継承しているので、ハンドラー側を (e: MouseEvent) と書いてもそのまま渡せます。
e.targetとe.currentTargetの型
e.target と e.currentTarget は、どちらも EventTarget | null と表示されました。入力値を読むには、次のどちらかで要素の型に絞り込みます。
| 方法 | コード | 向いている場面 |
|---|---|---|
| 型アサーション | (e.target as HTMLInputElement).value |
その要素にしかハンドラーを付けていない場合 |
| instanceof で絞り込む | if (e.target instanceof HTMLSelectElement) … |
子要素からイベントが上がってくる場合 |
generic属性でジェネリックコンポーネントを書く
script タグの generic 属性に型引数を書くと、渡された props に合わせて型が決まるコンポーネントになります。関数の <T> の中身をそのまま書く感覚で、extends による制約や複数の型引数(カンマ区切り)も使えます。
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ items: T[]; selected?: T }>()
const emit = defineEmits<{ select: [item: T] }>()
</script>
コンポーネントの中で items は T[]、computed(() => items[0]) は ComputedRef<T> と表示されました。親から Task の配列を渡すと T が Task に決まり、select イベントの引数やスロットの item も Task として扱えます。制約に合わない配列を渡したときの表示は、次の章の「ケース7」で紹介します。
vue-tscのエラー文と直し方:7つの再現例
ここからは、型が合わないコードを実際に vue-tsc にかけて出た表示と、その直し方です。行番号と列番号は再現用ファイルのものです。
ケース1:propsに宣言と違う値を渡した(TS2322)
<TaskCard :task="task" size="xl" />
src/err/E1.vue(8,26): error TS2322: Type '"xl"' is not assignable to type '"sm" | "md" | "lg" | undefined'.
読み方:「xl は sm・md・lg のどれにも代入できない」。直し方:宣言にある値(size="lg" など)にするか、子の型に 'xl' を足します。
ケース2:必須のpropsを渡していない(TS2345)
<TaskCard size="sm" />
src/err/E2.vue(6,4): error TS2345: Argument of type '{ size: "sm"; }' is not assignable to parameter of type '{ readonly task: Task; readonly size?: "sm" | "md" | "lg" | undefined; … }'.
Property 'task' is missing in type '{ size: "sm"; }' but required in type '{ readonly task: Task; … }'.
長い表示ですが、見る場所は2行目の Property 'task' is missing です(上の表示は途中を … で省いています)。:task="task" を足すか、子の宣言を task?: Task にして省略可能にします。
ケース3:emitの引数やイベント名が宣言と違う(TS2345)
const emit = defineEmits<{ toggle: [id: number] }>()
emit('toggle', '1')
emit('remove', 1)
src/err/E3.vue(4,18): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
src/err/E3.vue(5,8): error TS2345: Argument of type '"remove"' is not assignable to parameter of type '"toggle"'.
1行目は引数の型、2行目はイベント名の違いです。emit('toggle', 1) に直すか、宣言に remove: [id: number] を追加します。
ケース4:e.target.valueをそのまま読んだ(TS18047・TS2339)
function onInput(e: Event) {
text.value = e.target.value
}
src/err/E4.vue(5,16): error TS18047: 'e.target' is possibly 'null'.
src/err/E4.vue(5,25): error TS2339: Property 'value' does not exist on type 'EventTarget'.
e.target が EventTarget | null であることが理由です。次のように絞り込むと通ります。
<script setup lang="ts">
import { ref } from 'vue'
const text = ref('')
function onInput(e: Event) {
const input = e.target as HTMLInputElement
text.value = input.value
}
function onChange(e: Event) {
if (e.target instanceof HTMLSelectElement) text.value = e.target.value
}
</script>
<template>
<input @input="onInput" />
<select @change="onChange"><option>A</option></select>
<p>{{ text }}</p>
</template>
ケース5:テンプレート参照や初期値なしのrefをそのまま使った(TS18047・TS18048)
const box = useTemplateRef<HTMLDivElement>('box')
const price = ref<number>()
onMounted(() => {
console.log(box.value.offsetWidth)
console.log(price.value.toFixed(2))
})
src/err/E5.vue(6,15): error TS18047: 'box.value' is possibly 'null'.
src/err/E5.vue(7,15): error TS18048: 'price.value' is possibly 'undefined'.
null の可能性は TS18047、undefined の可能性は TS18048 と番号が分かれます。?. と ??、または if による絞り込みで通ります。
<script setup lang="ts">
import { ref, useTemplateRef, onMounted } from 'vue'
const box = useTemplateRef<HTMLDivElement>('box')
const price = ref<number>()
const width = ref(0)
onMounted(() => {
width.value = box.value?.offsetWidth ?? 0
if (price.value !== undefined) console.log(price.value.toFixed(2))
})
</script>
<template><div ref="box">{{ width }}</div></template>
ケース6:refへの代入・ハンドラーの引数・テンプレートの変数名(TS2322・TS2339)
<script setup lang="ts">
import { ref } from 'vue'
import TaskCard from '../TaskCard.vue'
const task = ref({ id: 1, title: '見積もりを送る', done: false })
function onToggle(id: string) {
console.log(id)
}
const count = ref(0)
count.value = '1'
</script>
<template>
<TaskCard :task="task" @toggle="onToggle" />
<p>{{ cnt }}</p>
</template>
src/err/E6.vue(9,1): error TS2322: Type 'string' is not assignable to type 'number'.
src/err/E6.vue(13,27): error TS2322: Type '(id: string) => void' is not assignable to type '(id: number) => any'.
Types of parameters 'id' and 'id' are incompatible.
Type 'number' is not assignable to type 'string'.
src/err/E6.vue(14,9): error TS2339: Property 'cnt' does not exist on type 'ComponentPublicInstance<{}, {}, {}, …>'.
| 行 | 内容 | 直し方 |
|---|---|---|
| 9行目 | ref(0) に文字列を代入した |
count.value = 1、または Number('1') で変換 |
| 13行目 | 子は number を送るのに、親のハンドラーが string を受けている | onToggle(id: number) に直す |
| 14行目 | template で宣言していない変数名 cnt を使った |
count に直す |
template の中の変数名の打ち間違いまで見つけてくれるのが、vue-tsc を走らせる大きな利点です。
ケース7:ジェネリックコンポーネントの制約に合わない(TS2322)
<script setup lang="ts">
import { ref } from 'vue'
import SelectList from '../SelectList.vue'
const names = ref(['a', 'b'])
</script>
<template>
<SelectList :items="names" />
</template>
src/err/E7.vue(8,16): error TS2322: Type 'string[]' is not assignable to type '{ id: number; }[]'.
Type 'string' is not assignable to type '{ id: number; }'.
T extends { id: number } の制約に対して文字列の配列を渡したためです。[{ id: 1, name: 'a' }] のように id を持つオブジェクトの配列にすると通ります。
エラー番号の逆引き表
| 番号 | 意味 | Vue でよく出る場面 | 直し方の方向 |
|---|---|---|---|
| TS2322 | 型が代入できない | props の値、ref への代入、イベントハンドラー | 渡す値か宣言の型をそろえる |
| TS2345 | 引数の型が合わない | emit の引数・イベント名、必須 props の不足 | 引数を直す、宣言に追加する |
| TS2339 | プロパティが存在しない | e.target.value、template の変数名 |
型を絞り込む、名前を直す |
| TS18047 | null の可能性がある | テンプレート参照、e.target |
?. や if で絞り込む |
| TS18048 | undefined の可能性がある | 初期値なしの ref、省略可能な props | ?? でデフォルト値、if で絞り込む |
| TS2769 | 合うオーバーロードがない | 分割代入した props を watch に直接渡した | () => size と関数で包む |
| TS2307 | モジュールが見つからない | tsc で .vue を import した | vue-tsc で実行する |
型を書くときのチェックリスト
| 確認すること | 目安 |
|---|---|
script タグに lang="ts" があるか |
SFC ごとに必要 |
| tsconfig.json の strict が true か | null・undefined のチェックが有効になる |
| props は型引数で宣言しているか | 省略可能な項目には ? |
| デフォルト値の書き方は Vue の版に合っているか | 3.5以降は分割代入、3.4までは withDefaults |
| emit のイベント名と引数を宣言したか | 3.3以降は名前付きタプル |
| 空配列・null で始まる ref に型引数を付けたか | ref<Item[]>([]) |
テンプレート参照を ?. で触っているか |
マウント前は null |
| イベントハンドラーの引数に型を付けたか | Event、KeyboardEvent など |
| ビルド前に vue-tsc を走らせているか | scripts に typecheck を登録 |
よくある質問
Q. env.d.ts(.vue 用の型宣言ファイル)は必要ですか?
A. 今回の構成では、env.d.ts を置かずに vue-tsc だけで全ファイルの型チェックが通りました。vue-tsc が SFC を直接読むためです。tsc 単体で .ts ファイルだけをチェックする構成では、declare module '*.vue' を書いた宣言ファイルを置くと、.vue の import で TS2307 が出なくなりました(この場合、SFC の中身まではチェックされません)。
Q. ref と reactive で型の書き方は変わりますか?
A. ref は ref<型>(初期値) と型引数で、reactive は const state: 型 = reactive({...}) と変数側の注釈で書きます。どちらも初期値から推論できるときは省略できます。
Q. props を分割代入するとリアクティブでなくなりませんか?
A. Vue 3.5 以降の script setup では、defineProps から分割代入した変数はリアクティブなまま使えます。watch に渡すときだけ () => size と関数で包みます。包まずに渡すと、vue-tsc とコンパイラーの両方がメッセージで知らせてくれます。
Q. defineModel の型はどう書きますか?
A. defineModel<string>() と型引数で書きます。vue-tsc の表示は ModelRef<string | undefined, string, …> でした。defineModel<number>('count', { required: true }) のように required を付けると、undefined が外れて ModelRef<number, string, number, number> になります。
まとめ
- props は defineProps の型引数で宣言し、Vue 3.5 以降は分割代入の
=でデフォルト値を付ける - emit は 3.3 以降の名前付きタプル記法(
change: [id: number])が簡潔で、親のハンドラーまでチェックされる - ref・computed は推論に任せ、空配列や null から始めるときだけ型引数を足す
- テンプレート参照は useTemplateRef。値は null を含むので
?.で触る - イベントハンドラーの引数には Event などの型を付け、
e.targetは as か instanceof で絞り込む - generic 属性で、渡した配列の型に合わせて動くコンポーネントが書ける
- vue-tsc --noEmit を走らせると、template の中の型や変数名まで一括で確認できる(vue-tsc 3.3.12 は TypeScript 6 系と組み合わせる)
参考・出典
- Vue.js 公式ドキュメント「TypeScript で Composition API を使用する」 https://ja.vuejs.org/guide/typescript/composition-api
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue TypeScriptの型の書き方|props・emit・ref早見表」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。