Chapter 10

移行ガイド

既存の JavaScript React プロジェクトを TypeScript に移行する方法を学びます

25 min

このチャプターで学ぶこと

  • 段階的な移行戦略を理解できる
  • tsconfig.json の移行用設定を使える
  • JSX から TSX への変換手順を実践できる
  • 型定義がないライブラリへの対処法を知る

移行ガイド

TypeScript コースの最終チャプターです。ここでは、既存の JavaScript React プロジェクトを TypeScript に段階的に移行する方法を学びます。「全部一気に書き換えなきゃ」と思う必要はありません。JavaScript と TypeScript は同じプロジェクト内で共存できるため、少しずつ安全に移行できます。

段階的な移行戦略

一括移行 vs 段階的移行

TypeScript への移行には 2 つのアプローチがあります。

アプローチメリットデメリット
一括移行一度で完了する巨大な PR になり、レビューが困難
段階的移行リスクが小さく、通常業務と並行可能完了まで時間がかかる

ほとんどのチームでは段階的移行を選びます。実務では新機能の開発やバグ修正と並行して進める必要があるため、一括移行は現実的ではありません。

📝 Rails のバージョンアップと同じ考え方

Rails 6 から Rails 7 にアップグレードするとき、一気にすべてを変更するのではなく、deprecation warning を潰しながら段階的に進めますよね。TypeScript への移行も同じ考え方です。

移行の全体像

移行は大きく 4 つのフェーズで進めます。

フェーズ 1: 環境準備
  TypeScript をインストールし、tsconfig.json を設定する

フェーズ 2: ゆるい設定で始める
  strict: false で .jsx を .tsx にリネームしていく

フェーズ 3: 型を追加する
  コンポーネントの Props・State に型を付けていく

フェーズ 4: strict モードを有効にする
  strict: true にして残りの型エラーを解消する

フェーズ 1: 環境準備

TypeScript のインストール

既存の React プロジェクトに TypeScript を追加します。

# TypeScript と React の型定義をインストール
npm install --save-dev typescript @types/react @types/react-dom

tsconfig.json の作成

移行用の ゆるい設定 で始めます。

{
  "compilerOptions": {
    // 基本設定
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],

    // 移行用のゆるい設定(後で strict: true にする)
    "strict": false,
    "noImplicitAny": false,

    // JavaScript ファイルも許可する(混在させるため)
    "allowJs": true,

    // 出力設定
    "outDir": "./dist",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src"]
}
💡 重要な設定の解説
  • allowJs: true.js.ts ファイルを混在できる。これが段階的移行の鍵です
  • strict: false — 厳密な型チェックを無効にする。最初はゆるく始めて、移行が進んだら有効にします
  • noImplicitAny: false — 型推論できない場合に暗黙的に any を許可する。最初は許容して、後で潰していきます
  • skipLibCheck: true — node_modules 内の型エラーをスキップ。移行中のノイズを減らします

Vite プロジェクトの場合

React コースで作ったプロジェクト(Vite ベース)の場合、Vite は TypeScript をネイティブでサポートしているため追加設定は最小限です。

# Vite プロジェクトでは TypeScript プラグインの追加は不要
# vite.config.js も .ts にリネームできるが、後回しでもOK

フェーズ 2: ファイルのリネーム

.jsx を .tsx にリネームする順序

リネームの順番が重要です。依存関係のツリーの末端(リーフコンポーネント)から始めます

推奨する移行順序:

1. 共有の型定義ファイルを作る(src/types/index.ts)
2. ユーティリティ関数(.js → .ts)
3. リーフコンポーネント(他のコンポーネントに依存しないもの)
4. 中間コンポーネント
5. ページコンポーネント
6. App.tsx(ルートコンポーネント)
7. main.tsx(エントリーポイント)

なぜリーフから始めるかというと、他のファイルへの影響が最小限だからです。ルートから始めると、すべての子コンポーネントに型の不整合が波及します。

📝 Laravel でいうと

Blade コンポーネントを Livewire に移行するとき、末端のパーツコンポーネント(ボタン、インプットなど)から始めるのと同じ発想です。レイアウトテンプレートから手を付けると影響範囲が大きくなりすぎます。

リネームの具体的な手順

ファイルを 1 つずつ移行していきます。

# ステップ 1: ファイルをリネーム
mv src/components/Button.jsx src/components/Button.tsx

# ステップ 2: エディタで開いてエラーを確認

# ステップ 3: エラーを修正

# ステップ 4: 動作確認(ブラウザで表示が壊れていないか)

# ステップ 5: コミット
git add src/components/Button.tsx
git commit -m "chore: Button.jsx を TypeScript に移行"
⚠️ 注意

1 ファイルの移行ごとにコミットすることを強く推奨します。問題が起きたときにすぐに元に戻せます。「10 ファイルまとめてリネーム」はリスクが高いです。

フェーズ 3: 型を追加する

コンポーネントに型を付ける(Before / After)

React コースで作ったコンポーネントを実際に TypeScript 化してみましょう。

Before(JavaScript):

// src/components/Profile.jsx
function Profile({ name, bio, imageUrl, hobbies = [] }) {
  return (
    <section>
      {imageUrl && (
        <img src={imageUrl} alt={`${name}のプロフィール写真`} />
      )}
      <div>
        <h2>{name}</h2>
        <p>{bio}</p>
        {hobbies.length > 0 && (
          <ul>
            {hobbies.map((hobby) => (
              <li key={hobby}>{hobby}</li>
            ))}
          </ul>
        )}
      </div>
    </section>
  );
}

export default Profile;

After(TypeScript):

// src/components/Profile.tsx

// まず Props の型を定義する
type ProfileProps = {
  name: string;
  bio: string;
  imageUrl?: string;        // 省略可能なので ?
  hobbies?: string[];       // 省略可能、デフォルト値あり
};

function Profile({ name, bio, imageUrl, hobbies = [] }: ProfileProps) {
  return (
    <section>
      {imageUrl && (
        <img src={imageUrl} alt={`${name}のプロフィール写真`} />
      )}
      <div>
        <h2>{name}</h2>
        <p>{bio}</p>
        {hobbies.length > 0 && (
          <ul>
            {hobbies.map((hobby) => (
              <li key={hobby}>{hobby}</li>
            ))}
          </ul>
        )}
      </div>
    </section>
  );
}

export default Profile;

変更点は 2 つだけです。

  1. ProfileProps 型を定義した
  2. 関数の引数に : ProfileProps を追加した

コンポーネントの中身は一切変わっていません。これが TypeScript 移行の基本です。

State を持つコンポーネントの移行

Before(JavaScript):

// src/components/Counter.jsx
import { useState } from 'react';

function Counter({ initialCount = 0, step = 1, onCountChange }) {
  const [count, setCount] = useState(initialCount);

  const increment = () => {
    const newCount = count + step;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  const decrement = () => {
    const newCount = count - step;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

export default Counter;

After(TypeScript):

// src/components/Counter.tsx
import { useState } from "react";

type CounterProps = {
  initialCount?: number;
  step?: number;
  onCountChange?: (count: number) => void; // コールバック関数の型
};

function Counter({
  initialCount = 0,
  step = 1,
  onCountChange,
}: CounterProps) {
  // useState の型は initialCount の型から自動推論される
  const [count, setCount] = useState(initialCount);

  const increment = () => {
    const newCount = count + step;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  const decrement = () => {
    const newCount = count - step;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

export default Counter;
💡 ヒント

useState の型引数はほとんどの場合省略可能です。初期値から自動推論されるためです。useState(0) なら numberuseState("") なら string と推論されます。明示的に指定が必要なのは useState<User | null>(null) のように初期値が null の場合です。

共通の型定義ファイルを作る

プロジェクト全体で使う型は、専用のファイルにまとめましょう。

// src/types/index.ts

// ユーザー関連の型
export type User = {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
};

// API レスポンスの共通型
export type ApiResponse<T> = {
  data: T;
  status: "success" | "error";
  message?: string;
};

// フォーム関連の型
export type ContactFormData = {
  name: string;
  email: string;
  message: string;
};
// 使う側
import type { User } from "../types";

type UserCardProps = {
  user: User;
  onEdit: (id: number) => void;
};

any との付き合い方

移行中の any は「技術的負債のマーカー」

移行中はどうしても any を使う場面が出てきます。大切なのは、any意図的に、一時的に使うことです。

// 悪い例:何も考えずに any を使う
const data: any = fetchSomething();

// 良い例:TODO コメント付きで any を使う
// TODO: API レスポンスの型を定義する (#123)
const data: any = fetchSomething();
💡 any を追跡する方法

プロジェクト内の any の数を定期的に確認する習慣をつけましょう。

# プロジェクト内の any の使用箇所を検索
grep -rn ": any" src/ --include="*.tsx" --include="*.ts"

移行の進捗指標として「any の残り数」を使うと、ゴールが見えやすくなります。

any を段階的に排除する

any を潰すときは、以下の優先順位で進めます。

// ステップ 1: any → unknown(型安全だが使いにくい)
const data: unknown = fetchSomething();

// ステップ 2: unknown → 型ガード付き
function isUser(data: unknown): data is User {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "name" in data
  );
}

if (isUser(data)) {
  console.log(data.name); // 安全にアクセスできる
}

// ステップ 3: 最初から正しい型を返す関数に書き換える
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: User = await response.json();
  return data;
}

型定義がないライブラリへの対処法

3 つの対処パターン

サードパーティライブラリの型定義がない場合、段階的に対処します。

// パターン 1: @types パッケージを探す(最優先)
// npm install --save-dev @types/ライブラリ名

// パターン 2: 最低限の型宣言を書く
// src/types/legacy-chart-lib.d.ts
declare module "legacy-chart-lib" {
  // 使っている関数だけ型を定義する
  export function createChart(
    element: HTMLElement,
    options: {
      type: "bar" | "line" | "pie";
      data: number[];
      labels: string[];
    }
  ): void;

  export function updateChart(data: number[]): void;
}

// パターン 3: any で宣言する(最終手段)
// src/types/very-old-lib.d.ts
declare module "very-old-lib";
// これだけで import できるようになる(全て any 扱い)
⚠️ 注意

パターン 3 は型安全性がゼロになるため、本当に他に方法がない場合のみ使いましょう。移行初期のブロッカー解消用と割り切り、後から型を追加するタスクを忘れずに作成してください。

フェーズ 4: strict モードの有効化

strict: true に切り替える

移行がある程度進んだら、tsconfig.json の設定を厳しくしていきます。

{
  "compilerOptions": {
    // 一気に strict: true にするのではなく、個別に有効化していく
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true

    // 上記をすべて有効にしたら、まとめて strict: true に置き換える
    // "strict": true
  }
}
💡 段階的に厳しくする

strict: true は上記すべてのオプションをまとめて有効にするショートカットです。一気に有効にすると大量のエラーが出る可能性があるため、1 つずつ有効にしてエラーを潰していく方が安全です。特に影響が大きいのは noImplicitAnystrictNullChecks です。

strictNullChecks で見つかる典型的なエラー

strictNullChecks を有効にすると、nullundefined の可能性がある箇所がすべてエラーになります。

// strictNullChecks: true でエラーになる例
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User>(); // User | undefined

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  // エラー!user が undefined の可能性がある
  return <h1>{user.name}</h1>;

  // 修正方法 1: 条件付きレンダリング
  if (!user) return <p>読み込み中...</p>;
  return <h1>{user.name}</h1>;

  // 修正方法 2: オプショナルチェーン
  return <h1>{user?.name ?? "読み込み中..."}</h1>;
}

これは TypeScript の大きな恩恵です。「null チェックし忘れ」というバグをコンパイル時に防止できます。

ベストプラクティスまとめ

移行を成功させるための心得をまとめます。

1. 完璧を求めない

“Perfect is the enemy of good” — 完璧は善の敵

最初から完璧な型を付ける必要はありません。any を使ってでもまず移行を進め、後から改善する方が生産的です。

2. 新規ファイルは最初から TypeScript で

移行中でも、新しく作るファイルは .tsx / .ts で作りましょう。JavaScript のファイルを増やすのは後退です。

3. チーム全体で合意する

移行はチーム全体のタスクです。以下のルールをチームで決めておきましょう。

4. CI で型チェックを回す

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build"
  }
}

tsc --noEmit を CI に組み込むことで、型エラーのあるコードがマージされるのを防げます。

✍ やってみよう:React コースの Counter を TypeScript 化しよう

React コースのチャプター 4 で作った「カウンター」アプリを TypeScript に移行してみましょう。

移行対象のコード(JavaScript):

// src/App.jsx
import { useState } from 'react';

function Counter({ label, initialCount, step }) {
  const [count, setCount] = useState(initialCount);

  return (
    <div style={{ textAlign: 'center', padding: '1rem' }}>
      <h2>{label}</h2>
      <p style={{ fontSize: '2rem' }}>{count}</p>
      <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
        <button onClick={() => setCount(prev => prev - step)}>
          -{step}
        </button>
        <button onClick={() => setCount(0)}>
          リセット
        </button>
        <button onClick={() => setCount(prev => prev + step)}>
          +{step}
        </button>
      </div>
    </div>
  );
}

function App() {
  const [total, setTotal] = useState(0);

  return (
    <div>
      <h1>カウンターアプリ</h1>
      <Counter label="カウンターA" initialCount={0} step={1} />
      <Counter label="カウンターB" initialCount={10} step={5} />
    </div>
  );
}

export default App;

やること:

  1. ファイルを App.tsx にリネームする(実際にはコピーして書き直す)
  2. Counter コンポーネントの Props 型(CounterProps)を定義する
  3. 各 Props の型を決める:
    • label: 文字列
    • initialCount: 数値(省略時は 0
    • step: 数値(省略時は 1
  4. useState の型が正しく推論されているか確認する
  5. エディタで赤い波線(型エラー)が出ないことを確認する

模範解答:

// src/App.tsx
import { useState } from "react";

// Props の型を定義
type CounterProps = {
  label: string;
  initialCount?: number;
  step?: number;
};

function Counter({ label, initialCount = 0, step = 1 }: CounterProps) {
  // useState の型は initialCount(number)から自動推論される
  const [count, setCount] = useState(initialCount);

  return (
    <div style={{ textAlign: "center", padding: "1rem" }}>
      <h2>{label}</h2>
      <p style={{ fontSize: "2rem" }}>{count}</p>
      <div style={{ display: "flex", gap: "0.5rem", justifyContent: "center" }}>
        <button onClick={() => setCount((prev) => prev - step)}>
          -{step}
        </button>
        <button onClick={() => setCount(0)}>リセット</button>
        <button onClick={() => setCount((prev) => prev + step)}>
          +{step}
        </button>
      </div>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>カウンターアプリ</h1>
      <Counter label="カウンターA" />
      <Counter label="カウンターB" initialCount={10} step={5} />

      {/* 以下はエラーになることを確認しよう */}
      {/* <Counter /> */}
      {/* label が必須なのでエラー */}

      {/* <Counter label="C" step="3" /> */}
      {/* step は number なのに string を渡しているのでエラー */}
    </div>
  );
}

export default App;

チャレンジ:

  • onCountChange?: (count: number) => void を Props に追加して、カウントが変わったときに親コンポーネントに通知する仕組みを作ってみよう
  • Counter を別ファイル(src/components/Counter.tsx)に切り出して、import / export の TypeScript 版を体験してみよう

コース全体のまとめと次のステップ

TypeScript コースお疲れさまでした! 全 10 チャプターを通して、以下の内容を学びました。

チャプター学んだこと
0. 環境構築TypeScript + React の開発環境セットアップ
1. 基本の型string, number, boolean, 配列, オブジェクト
2. 型注釈と型推論明示的な型注釈と TypeScript の自動推論
3. インターフェースと型エイリアスinterfacetype でオブジェクトの形を定義
4. ユニオン型とリテラル型複数の型を組み合わせる | と型の絞り込み
5. 関数の型パラメータ・戻り値・コールバックの型定義
6. ジェネリクス型をパラメータ化して汎用的なコードを作成
7. React + TS 基礎Props・State・イベントに型を付ける
8. React + TS HooksuseRef・useContext・カスタムフックの型
9. 実践パターンas const・never・判別付きユニオン
10. 移行ガイド既存プロジェクトの段階的な TypeScript 化

次のステップ

TypeScript の学習をさらに深めるためのリソースを紹介します。

公式ドキュメント:

React + TypeScript 特化:

実践:

💡 最も効果的な学習法

TypeScript は書いて覚えるのが一番です。公式ドキュメントを読むだけでなく、実際のプロジェクトで使いながら、エラーメッセージと向き合い、型の書き方を体に染み込ませていきましょう。最初は面倒に感じますが、1-2 週間もすれば「型がないと不安」と感じるようになります。

TypeScript は、バックエンドでいえば PHPStan や RuboCop のような静的解析をさらに強力にしたものです。コードを書く段階でバグを防ぎ、リファクタリングを安全にし、チーム開発を円滑にしてくれます。ぜひ今日から、あなたのプロジェクトに TypeScript を取り入れてみてください。