T-CREATOR

<div />

Vue 3とTypeScriptをセットアップして型安全に始める手順 propsとemitsの設計も整理

2026年1月9日
Vue 3とTypeScriptをセットアップして型安全に始める手順 propsとemitsの設計も整理

Vue 3 と TypeScript のセットアップで迷っている方、特に props と emits の型定義方法の違い型推論を活かした設計判断に悩んでいる方に向けて、実務で検証した手順と判断基準を整理しました。本記事では、初学者でも理解できるセットアップから、実務者が納得できる設計の比較まで、1 記事で完結させます。

propsとemitsの定義方法

#定義方法型推論記述量型安全性実務での採用判断
1defineProps<T>()少ない高いジェネリック型を使う場面で推奨
2withDefaults()中程度高いデフォルト値が必要な場合に推奨
3ランタイム宣言多い中程度Vue 2 からの移行時に一時採用
4defineEmits<T>()少ない高いイベント駆動設計で必須
5Tuple syntax中程度高いイベント引数を厳密に制御したい場合

検証環境

  • OS: macOS Sequoia 15.2
  • Node.js: 24.12.0 (LTS)
  • TypeScript: 5.9.3
  • 主要パッケージ:
    • vue: 3.5.25
    • vite: 6.2.1
    • @vitejs/plugin-vue: 5.2.1
  • 検証日: 2026 年 1 月 9 日

Vue 3とTypeScriptにおけるpropsとemitsの型安全性が重要になる背景

TypeScript導入がVue開発にもたらす変化

Vue 3 では Composition API の導入により、TypeScript との統合が飛躍的に向上しました。特に props と emits の型定義は、コンポーネント間の契約を明示する重要な役割を果たします。Options API では実現が難しかった型推論が、<script setup> と組み合わせることで自然に機能するようになりました。

型推論(Type Inference)とは、開発者が明示的に型を書かなくても、TypeScript が自動的に型を推定してくれる機能です。これにより、冗長なコード記述を減らしつつ、IDE の補完や型チェックの恩恵を受けられます。

しかし、型推論に頼りすぎると、複雑なコンポーネントでは型が曖昧になり、バグの温床になる可能性もあります。実際に検証したところ、props のオプショナルプロパティemits の引数型を明示しないと、親子コンポーネント間でのデータ受け渡しで実行時エラーが発生しました。

セットアップ手順における設計判断の難しさ

Vue 3 + TypeScript のセットアップでは、以下のような判断が必要になります。

  • props の型定義は型推論に任せるべきか、明示的に書くべきか
  • emits のイベント名と引数の型はどこまで厳密に定義するべきか
  • デフォルト値を持つ props はどう定義するのが最適か
  • インターフェース定義はコンポーネント内に書くべきか、別ファイルに切り出すべきか

これらの判断を誤ると、開発初期は問題なくても、コンポーネントが増えるにつれて保守性が低下します。

mermaidflowchart LR
  setup["プロジェクトセットアップ"] --> choice["型定義方法の選択"]
  choice --> explicit["明示的型定義"]
  choice --> inference["型推論"]
  explicit --> safe["型安全性<br/>高い"]
  inference --> risk["型曖昧化<br/>リスクあり"]
  safe --> maintain["保守性向上"]
  risk --> bug["実行時エラー"]

上の図は、セットアップ時の型定義方法の選択が、開発後半の保守性に直結することを示しています。初期の判断ミスが後から修正コストを生む典型的なパターンです。

Vue 3 + TypeScript開発で直面する型安全性の課題

propsの型定義における実務上の問題

実際にプロジェクトを進めると、以下のような問題が頻発しました。

問題1: オプショナルプロパティの扱い

typescript// 型推論だけに頼った場合
const props = defineProps({
  userId: Number,
  userName: String,
});

このコードは一見問題なさそうですが、userIdundefined の可能性を考慮していません。親コンポーネントから props を渡し忘れた場合、実行時エラーが発生します。

問題2: デフォルト値と型の不一致

typescript// デフォルト値が型と合わない例
const props = defineProps({
  count: {
    type: Number,
    default: "0", // 文字列を設定してしまう
  },
});

ランタイム宣言では、TypeScript の型チェックが効かず、デフォルト値の型ミスに気づきにくい問題がありました。

emitsの型定義における実務上の問題

問題3: イベント名のタイポ

typescript// emit時にイベント名を間違える
const emit = defineEmits(["update:modelValue"]);

// 実装時に間違える
emit("updateModelValue", newValue); // タイポに気づかない

イベント名が文字列リテラルのみの場合、タイポによるバグが発見しにくくなります。

問題4: イベント引数の型チェック漏れ

typescript// 引数の型が明示されていない
const emit = defineEmits(["user-selected"]);

// 呼び出し時に間違った型を渡してもエラーにならない
emit("user-selected", "invalid-data"); // 本来はUserオブジェクトを渡すべき

実際の業務では、親コンポーネントで受け取る際に型エラーが発生し、原因特定に時間がかかりました。

つまずきポイント

  • 型推論だけでは、オプショナルプロパティの扱いが不明確になる
  • ランタイム宣言では、TypeScript の型チェックが部分的にしか効かない
  • emit のイベント名と引数の型が曖昧だと、実行時エラーの原因になる

propsとemitsの型安全な定義方法と実務での判断基準

propsの型定義方法の比較と選択基準

この章では、props の型定義方法を 3 つ紹介し、それぞれの採用判断を説明します。

方法1: ジェネリック型を使った型定義(推奨)

型推論を最大限に活かしつつ、型安全性を確保する方法です。

typescriptinterface UserProps {
  userId: number;
  userName: string;
  isActive?: boolean;
  avatar?: string;
}

const props = defineProps<UserProps>();

採用した理由:

  • TypeScript の型チェックが完全に効く
  • IDE の補完が強力に機能する
  • オプショナルプロパティが明確に定義できる

採用しなかった理由:

  • デフォルト値を設定する場合は withDefaults() が別途必要

方法2: withDefaultsを使ったデフォルト値付き定義(推奨)

デフォルト値が必要な場合は、withDefaults() を組み合わせます。

typescriptinterface ButtonProps {
  label: string;
  variant?: "primary" | "secondary" | "danger";
  size?: "small" | "medium" | "large";
  disabled?: boolean;
}

const props = withDefaults(defineProps<ButtonProps>(), {
  variant: "primary",
  size: "medium",
  disabled: false,
});

採用した理由:

  • デフォルト値と型定義を両立できる
  • オプショナルプロパティのデフォルト値を明示できる
  • 型推論が効いたまま、実行時の安全性も確保できる

実際に試したところ、withDefaults() を使うことで、親コンポーネントから props を渡し忘れても、デフォルト値が適用され、実行時エラーを防げました。

方法3: ランタイム宣言(非推奨)

typescriptconst props = defineProps({
  userId: {
    type: Number,
    required: true,
  },
  userName: {
    type: String,
    required: true,
  },
});

採用しなかった理由:

  • TypeScript の型チェックが部分的にしか効かない
  • 記述量が多くなる
  • 型定義とランタイム検証が分離してしまう

業務で問題になったのは、この方法では IDE の補完が弱く、props の型を間違えても気づきにくかったことです。

emitsの型定義方法の比較と選択基準

方法1: タプル構文を使った厳密な型定義(推奨)

イベント名と引数の型を厳密に定義する方法です。

typescriptinterface User {
  id: number;
  name: string;
  email: string;
}

const emit = defineEmits<{
  "user-selected": [user: User];
  "user-deleted": [userId: number];
  "error-occurred": [error: Error];
  "form-submitted": [formData: Record<string, any>];
}>();

採用した理由:

  • イベント名のタイポを防げる
  • 引数の型が厳密にチェックされる
  • IDE の補完が正確に機能する

実際の検証では、親コンポーネントで @user-selected イベントをハンドリングする際、引数の型が自動的に推論され、型安全なイベント処理が実現できました。

方法2: 関数シグネチャ形式(推奨)

より明示的にイベントの型を定義する方法です。

typescriptinterface FormEmits {
  (e: "submit", data: FormData): void;
  (e: "cancel"): void;
  (e: "field-change", field: string, value: any): void;
}

const emit = defineEmits<FormEmits>();

採用した理由:

  • イベントごとに関数シグネチャが明確になる
  • 複雑なイベント引数を定義しやすい

採用しなかった理由:

  • タプル構文の方が直感的で読みやすい

方法3: 文字列配列(非推奨)

typescriptconst emit = defineEmits(["update:modelValue", "change"]);

採用しなかった理由:

  • イベント名のタイポに気づけない
  • 引数の型チェックが効かない
  • IDE の補完が弱い

検証の結果、この方法では実行時エラーが頻発し、デバッグに時間がかかりました。

つまずきポイント

  • ジェネリック型を使わないと、型推論の恩恵を受けられない
  • withDefaults() を使わないと、デフォルト値と型定義を両立できない
  • emit の型定義を省略すると、イベント駆動設計の型安全性が失われる
mermaidflowchart TB
  start["props/emits定義"] --> generic{"ジェネリック型<br/>使用?"}
  generic -->|Yes| defaults{"デフォルト値<br/>必要?"}
  generic -->|No| runtime["ランタイム宣言<br/>型安全性△"]
  defaults -->|Yes| withdef["withDefaults()<br/>型安全性◎"]
  defaults -->|No| simple["defineProps&lt;T&gt;()<br/>型安全性◎"]
  withdef --> safe["型安全な<br/>コンポーネント"]
  simple --> safe
  runtime --> risk["型チェック<br/>不十分"]

この図は、props 定義時の判断フローを示しています。ジェネリック型を使うことが、型安全性確保の第一歩です。

プロジェクトセットアップから実装までの具体例

Vue 3 + TypeScript プロジェクトの作成手順

動作確認済みの手順を以下に示します。

bash# Viteを使ったプロジェクト作成
npm create vue@latest my-vue-app

# プロジェクト設定での選択
# ✅ TypeScript を選択
# ✅ Router を選択(必要に応じて)
# ✅ ESLint を選択
# ✅ Prettier を選択

# プロジェクトディレクトリに移動
cd my-vue-app

# 依存関係のインストール
npm install

# 開発サーバー起動
npm run dev

この手順で作成すると、TypeScript の設定ファイル(tsconfig.json)が自動生成されます。

tsconfig.jsonの重要な設定項目

生成された tsconfig.json の中で、型安全性に関わる重要な設定を確認します。

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

重要な設定項目:

  • "strict": true: 厳格な型チェックを有効化
  • "noUnusedLocals": true: 未使用の変数を検出
  • "jsx": "preserve": Vue の JSX サポート

実際に試したところ、"strict": true を有効にすることで、props の型定義漏れや emit の引数型の不一致を開発時に検出できました。

型安全なコンポーネントの実装例

基本的なpropsとemitsの定義

vue<!-- src/components/UserCard.vue -->
<script setup lang="ts">
import { computed } from "vue";

// インターフェース定義
interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}

// props定義(ジェネリック型)
interface UserCardProps {
  user: User;
  showAvatar?: boolean;
  showEmail?: boolean;
}

const props = withDefaults(defineProps<UserCardProps>(), {
  showAvatar: true,
  showEmail: true,
});

// emits定義(タプル構文)
const emit = defineEmits<{
  "user-clicked": [user: User];
  "edit-clicked": [userId: number];
  "delete-clicked": [userId: number];
}>();

// computed プロパティ(型推論が効く)
const displayName = computed(() => {
  return props.user.name || "Unknown User";
});

// イベントハンドラー(型安全)
const handleUserClick = () => {
  emit("user-clicked", props.user);
};

const handleEditClick = () => {
  emit("edit-clicked", props.user.id);
};

const handleDeleteClick = () => {
  emit("delete-clicked", props.user.id);
};
</script>

<template>
  <div class="user-card" @click="handleUserClick">
    <img
      v-if="showAvatar && user.avatar"
      :src="user.avatar"
      :alt="user.name"
      class="avatar"
    />
    <div class="user-info">
      <h3>{{ displayName }}</h3>
      <p v-if="showEmail">{{ user.email }}</p>
    </div>
    <div class="actions">
      <button @click.stop="handleEditClick">編集</button>
      <button @click.stop="handleDeleteClick">削除</button>
    </div>
  </div>
</template>

<style scoped>
.user-card {
  display: flex;
  align-items: center;
  padding: 16px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  cursor: pointer;
}

.avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  margin-right: 16px;
}

.user-info {
  flex: 1;
}

.actions {
  display: flex;
  gap: 8px;
}

button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

このコードでは、以下の点を実現しています。

  • props の型定義がインターフェースで明確
  • デフォルト値が withDefaults() で設定されている
  • emit のイベント名と引数の型が厳密に定義されている
  • computed プロパティで型推論が効いている

親コンポーネントでの使用例

vue<!-- src/views/UserList.vue -->
<script setup lang="ts">
import { ref } from "vue";
import UserCard from "@/components/UserCard.vue";

interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}

const users = ref<User[]>([
  {
    id: 1,
    name: "田中太郎",
    email: "tanaka@example.com",
    avatar: "https://i.pravatar.cc/150?img=1",
  },
  {
    id: 2,
    name: "佐藤花子",
    email: "sato@example.com",
    avatar: "https://i.pravatar.cc/150?img=2",
  },
]);

// イベントハンドラー(型推論が効く)
const handleUserClicked = (user: User) => {
  console.log("User clicked:", user);
};

const handleEditClicked = (userId: number) => {
  console.log("Edit user:", userId);
};

const handleDeleteClicked = (userId: number) => {
  users.value = users.value.filter((u) => u.id !== userId);
};
</script>

<template>
  <div class="user-list">
    <h1>ユーザー一覧</h1>
    <UserCard
      v-for="user in users"
      :key="user.id"
      :user="user"
      :show-avatar="true"
      :show-email="true"
      @user-clicked="handleUserClicked"
      @edit-clicked="handleEditClicked"
      @delete-clicked="handleDeleteClicked"
    />
  </div>
</template>

<style scoped>
.user-list {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

h1 {
  margin-bottom: 20px;
}
</style>

親コンポーネントでは、UserCard コンポーネントの props と emits の型が自動的に推論され、IDE の補完が正確に機能します。

つまずきポイント

  • インターフェース定義をコンポーネント外に切り出すと、型のインポートが必要になる
  • emit のイベント名を @click.stop で伝播を止めないと、親のイベントも発火する
  • 配列操作で型推論が効かない場合は、明示的に型を指定する必要がある

型定義ファイルの分離と管理

プロジェクトが大きくなると、型定義を別ファイルに切り出す必要があります。

typescript// src/types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateUserRequest {
  name: string;
  email: string;
  avatar?: string;
}

export interface UpdateUserRequest {
  name?: string;
  email?: string;
  avatar?: string;
}
typescript// src/types/events.ts
import type { User } from "./user";

export interface UserEvents {
  "user-selected": [user: User];
  "user-created": [user: User];
  "user-updated": [user: User];
  "user-deleted": [userId: number];
}

コンポーネントで使用する際は、以下のようにインポートします。

vue<script setup lang="ts">
import type { User } from "@/types/user";
import type { UserEvents } from "@/types/events";

const props = defineProps<{
  user: User;
}>();

const emit = defineEmits<UserEvents>();
</script>

実際に試したところ、型定義を分離することで、複数のコンポーネント間で型定義を共有でき、保守性が向上しました。

propsとemitsの型定義方法:詳細比較まとめ

ここでは、記事冒頭で示した即答用比較表をより詳細に展開し、実務での判断材料を提供します。

props定義方法の詳細比較

定義方法型推論IDE補完デフォルト値記述量型チェック実務での推奨度
defineProps<T>()△(別途必要)厳格◎(ジェネリック型を使う場面)
withDefaults(defineProps<T>(), {})厳格◎(デフォルト値が必要な場合)
ランタイム宣言部分的△(Vue 2 からの移行時のみ)

採用判断の基準

ジェネリック型(defineProps<T>())を採用すべきケース:

  • props にデフォルト値が不要な場合
  • TypeScript の型チェックを最大限活用したい場合
  • 複雑な型定義(Union Types、Intersection Types)を使いたい場合

withDefaults を採用すべきケース:

  • props にデフォルト値が必要な場合
  • オプショナルプロパティのデフォルト値を明示したい場合
  • UI コンポーネント(ボタン、フォーム要素)で variant や size を扱う場合

ランタイム宣言を採用しなかった理由:

  • TypeScript の型チェックが部分的にしか効かない
  • IDE の補完が弱く、開発効率が低下する
  • 型定義とランタイム検証が分離し、保守性が低下する

業務で問題になったのは、ランタイム宣言では props の型を間違えても気づきにくく、実行時エラーの原因になることでした。

emits定義方法の詳細比較

定義方法型推論IDE補完イベント名チェック引数型チェック記述量実務での推奨度
タプル構文◎(イベント駆動設計で必須)
関数シグネチャ○(複雑なイベント引数の場合)
文字列配列××××(型安全性が失われる)

採用判断の基準

タプル構文を採用すべきケース:

  • イベント名と引数の型を厳密に定義したい場合
  • IDE の補完を最大限活用したい場合
  • イベント駆動設計を採用している場合

関数シグネチャを採用すべきケース:

  • イベントごとに複雑な引数を定義したい場合
  • より明示的にイベントの型を定義したい場合

文字列配列を採用しなかった理由:

  • イベント名のタイポに気づけない
  • 引数の型チェックが効かない
  • IDE の補完が弱く、開発効率が低下する

検証の結果、タプル構文を使うことで、イベント駆動設計の型安全性が大幅に向上しました。

型推論 vs 明示的型定義の判断基準

ケース型推論を活かす明示的に型を書く理由
単純な props型推論で十分
複雑な props明示的な方が意図が明確
デフォルト値ありwithDefaults で両立
イベント引数×明示的定義が必須
computed型推論が効きやすい

型推論を活かすべきケースと、明示的に型を書くべきケースを正しく判断することが、型安全な開発の鍵になります。

向いているケース / 向かないケース

ジェネリック型 + 型推論が向いているケース:

  • 新規プロジェクト
  • TypeScript の知識があるチーム
  • 型安全性を最優先する開発

ランタイム宣言が向かないケース:

  • 型安全性を重視する開発
  • 大規模プロジェクト
  • チーム開発(型の意図が伝わりにくい)

実際に試したところ、ジェネリック型を使った定義方法が、開発効率と型安全性のバランスが最も良いと判断しました。

まとめ

Vue 3 と TypeScript のセットアップにおいて、props と emits の型定義方法は開発の型安全性を左右する重要な判断ポイントです。本記事では、以下の点を実務検証に基づいて整理しました。

重要なポイント

props の型定義

  • ジェネリック型(defineProps<T>())を使うことで、型推論と型安全性を両立できる
  • デフォルト値が必要な場合は、withDefaults() を組み合わせる
  • ランタイム宣言は、TypeScript の型チェックが部分的にしか効かないため非推奨

emits の型定義

  • タプル構文を使うことで、イベント名と引数の型を厳密に定義できる
  • 文字列配列のみの定義は、型安全性が失われるため非推奨
  • イベント駆動設計では、emit の型定義が必須

セットアップの判断基準

  • 型推論を活かしつつ、複雑な部分は明示的に型を書く
  • インターフェース定義は、プロジェクトの規模に応じて分離する
  • tsconfig.json"strict": true を有効にして、厳格な型チェックを行う

実際に検証したところ、これらの判断基準に従うことで、開発時の型エラーを大幅に減らし、実行時エラーのリスクを最小化できました。

ただし、すべてのケースで型推論が最適とは限りません。プロジェクトの規模、チームの TypeScript 習熟度、UI/UX 要件に応じて、柔軟に判断することが重要です。型安全性を追求しつつ、開発効率とのバランスを取ることが、実務での成功につながります。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;