JavaScriptからTypeScriptへの段階的移行完全ガイド〜allowJs・JSDoc・strict段階適用〜【2026年版】

「既存の JavaScript プロジェクトに、ある日突然 TypeScript を全面導入する」── 多くの現場でこの選択肢は 非現実的 です。何千行ものコードを一晩で書き換えることはできず、ビルドが止まれば事業が止まります。本記事では、TypeScript 5.x を前提に 「動いている JavaScript を壊さずに、段階的に TypeScript へ移行する」 ための完全なロードマップを、40 個以上のコピペで動くコード断片とともに整理します。

allowJs による JS と TS の共存、// @ts-check と JSDoc を使った「ファイル拡張子を変えずに型を入れる」テクニック、strict を 1 オプションずつ有効化していく安全な順序、requireimport や CommonJS → ESM の置換、Jest → Vitest や Webpack → Vite といったツール側の移行、CI/CD と PR レビュー戦略まで、「明日から既存リポジトリで使える具体的な手順」として書き下ろしています。型システムそのものの解説は TypeScript 型基礎tsconfig.json の細部は tsconfig.json 完全ガイド を併読してください。

  1. 1. 移行前の現状把握とリスク評価
    1. 1.1 package.json から得られる情報
    2. 1.2 ファイル構成と規模の計測
    3. 1.3 移行ポリシーを 1 ページにまとめる
  2. 2. 共存フェーズ: allowJs と checkJs
    1. 2.1 最小の tsconfig.json
    2. 2.2 段階的に checkJs を有効化する
    3. 2.3 反対に「特定行だけ無視する」
  3. 3. JSDoc による型注釈という選択肢
    1. 3.1 基本のプリミティブ
    2. 3.2 オブジェクト型・配列・Union
    3. 3.3 ジェネリクスも書ける
    4. 3.4 外部ファイルの型定義を流用する
  4. 3.5 JSDoc → .d.ts への自動変換
  5. 4. ファイル拡張子の置換: .js → .ts
    1. 4.1 推奨の順序
    2. 4.2 拡張子変更の実コマンド
    3. 4.3 拡張子変更直後によく出る型エラーと対処
  6. 5. CommonJS から ESM へ
    1. 5.1 require / module.exports の置換
    2. 5.2 default export と named export
    3. 5.3 __dirname / __filename を ESM で復元
    4. 5.4 package.json の “type” 切替
  7. 6. strict オプションの段階適用
    1. 6.1 strict の構成要素
    2. 6.2 推奨の有効化順序
    3. 6.3 strictNullChecks への対応
    4. 6.4 noImplicitAny への対応
    5. 6.5 noUncheckedIndexedAccess への対応
    6. 6.6 exactOptionalPropertyTypes への対応
    7. 6.7 useUnknownInCatchVariables への対応
  8. 7. 型定義パッケージ (@types) と外部ライブラリ
    1. 7.1 @types のインストール
    2. 7.2 「型が無い」ライブラリを救う 3 つの手段
    3. 7.3 アンビエント宣言で「動的にロードされる定数」を型化
  9. 8. 自動移行ツール ts-migrate と jscodeshift
    1. 8.1 ts-migrate の導入
    2. 8.2 ts-migrate が自動でやってくれること
    3. 8.3 jscodeshift を使った require → import 変換
  10. 9. lint とフォーマッタの段階移行
    1. 9.1 typescript-eslint 導入
    2. 9.2 段階適用の .eslintrc
    3. 9.3 Prettier との衝突を避ける
  11. 10. テスト基盤の移行: Jest → Vitest
    1. 10.1 ts-jest による Jest の TS 対応
    2. 10.2 Vitest への置き換え
  12. 11. ビルドツールの移行: Webpack → Vite / tsc
    1. 11.1 tsc 単体での最小ビルド
    2. 11.2 Babel + TypeScript
    3. 11.3 Vite による開発サーバ + ビルド
  13. 12. フレームワーク別の移行: React / Express
    1. 12.1 React プロジェクトの段階移行
    2. 12.2 Express を TypeScript で書き直す
    3. 12.3 Node.js プロジェクトのモノレポ移行
  14. 13. CI/CD とレビュー戦略
    1. 13.1 GitHub Actions での型チェック
    2. 13.2 「型エラー数」を回帰指標にする
    3. 13.3 PR 単位のレビュー戦略
    4. 13.4 チーム合意のためのチェックリスト
  15. 14. よくある罠と回避策
    1. 14.1 「@ts-ignore の永久増殖」を防ぐ
    2. 14.2 「全部 any」病からの脱出
    3. 14.3 「any と unknown の混同」
    4. 14.4 「インデックスシグネチャでの穴」
    5. 14.5 「default export 地獄」
  16. 15. 移行完了の判定とこれから
    1. 15.1 移行 KPI の例
    2. 15.2 KPI 計測スクリプト
    3. 15.3 「終わった」あとに学習を継続する
  17. 16. まとめ: 段階移行 9 か条

1. 移行前の現状把握とリスク評価

移行の最初のステップはコードを書き換えることではなく、「現状の JavaScript プロジェクトがどう構成されているか」を機械的に棚卸しすることです。依存関係・ビルドツール・モジュール形式・テストカバレッジを正しく把握しないまま型を入れ始めると、必ず途中で「型エラーが多すぎて pull request が爆発する」事態を招きます。

1.1 package.json から得られる情報

# 依存パッケージの全体像
npm ls --depth=0

# 直接依存のみJSON出力
npm ls --depth=0 --json > deps.json

# 古いパッケージの洗い出し
npm outdated

# 脆弱性も同時に把握
npm audit --production

package.json"type" フィールド・"main" / "exports" / "scripts" も必ず確認します。"type": "module" が無い場合、Node.js は .js を CommonJS として扱います。

{
  "name": "legacy-app",
  "version": "1.0.0",
  "type": "commonjs",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.19.0",
    "lodash": "^4.17.21"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "webpack": "^5.90.0"
  }
}

1.2 ファイル構成と規模の計測

# .js ファイルの行数を可視化
find src -name "*.js" | xargs wc -l | sort -n | tail -20

# .js ファイル総数
find src -name "*.js" | wc -l

# require / import の出現箇所
grep -rn "require(" src | wc -l
grep -rn "module.exports" src | wc -l

require の総数 × 想定リファクタリング時間」を見積もりに直結させると、上長への説明にも使えます。

1.3 移行ポリシーを 1 ページにまとめる

# TypeScript 移行ポリシー(草案)

- Phase 0: 現状把握 (依存・規模・テスト網羅率)
- Phase 1: allowJs + checkJs で「JS のまま型エラーを観測」
- Phase 2: 末端モジュールから .ts へ拡張子変更
- Phase 3: strict を段階適用 (Null → Any → IndexAccess)
- Phase 4: ビルドツール (Vite/tsc) とテスト (Vitest) の置換
- Phase 5: CI で TypeScript エラー数を回帰検知

2. 共存フェーズ: allowJs と checkJs

TypeScript には 「JavaScript ファイルをそのまま型チェックの対象に含める」 モードが備わっています。これにより、.ts 化していないファイルでも、まずは「TypeScript コンパイラから見たエラー数」を可視化できます。

2.1 最小の tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "strict": false,
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

ポイントは "noEmit": true"checkJs": false です。最初は「型チェックだけ通す/出力はしない」状態にし、既存のビルドパイプライン (Webpack / Babel) を壊さないようにします。

2.2 段階的に checkJs を有効化する

// Before: tsconfig.json
// "checkJs": false  ← まずは黙らせる

// After: 個別ファイルだけ型チェック
// src/utils/calc.js の先頭に追加するだけ
// @ts-check

/**
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function add(a, b) {
  return a + b;
}

add(1, "2"); // ← TypeScript がエラーを報告する

// @ts-check を 1 行付け加えるだけで、その JS ファイルだけが型チェックされる」 という仕組みは、移行初期に非常に強力です。ファイル拡張子を変えないため、ビルドパイプラインも壊しません。

2.3 反対に「特定行だけ無視する」

// @ts-check

function legacy(input) {
  // @ts-ignore: 旧API が型未対応 (チケット #1234)
  return window.__OLD_GLOBAL__.run(input);
}

function legacyExpected(input) {
  // @ts-expect-error: ライブラリ更新後に削除予定
  return window.__OLD_GLOBAL__.run(input);
}

// @ts-ignore は「黙らせるだけ」、// @ts-expect-error は「エラーが消えたら逆にエラーになる」ため、後で必ず気づけるのが利点です。新規コードでは @ts-expect-error を優先します。

3. JSDoc による型注釈という選択肢

「拡張子は .js のまま、型だけ入れたい」── これは現場で非常によく出てくる要求です。Babel / Webpack / 既存の lint 設定をいじりたくない、というケースですね。このとき活躍するのが JSDoc 型注釈です。

3.1 基本のプリミティブ

// @ts-check

/**
 * @param {string} name
 * @param {number} age
 * @returns {string}
 */
function greet(name, age) {
  return `${name} is ${age}`;
}

// greet(1, "alice"); // ← エラー

3.2 オブジェクト型・配列・Union

// @ts-check

/**
 * @typedef {Object} User
 * @property {number} id
 * @property {string} name
 * @property {string} [nickname] - 省略可
 */

/** @type {User[]} */
const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob", nickname: "Bobby" },
];

/**
 * @param {User} user
 * @returns {"adult" | "child"}
 */
function classify(user) {
  return user.name.length > 5 ? "adult" : "child";
}

3.3 ジェネリクスも書ける

// @ts-check

/**
 * @template T
 * @param {T[]} arr
 * @returns {T | undefined}
 */
function head(arr) {
  return arr[0];
}

const n = head([1, 2, 3]);     // n: number | undefined
const s = head(["a", "b"]);    // s: string | undefined

3.4 外部ファイルの型定義を流用する

// types.d.ts (1 ファイル作るだけ)
export interface Product {
  id: string;
  name: string;
  price: number;
}
// src/cart.js
// @ts-check

/** @typedef {import('./types').Product} Product */

/**
 * @param {Product[]} list
 * @returns {number}
 */
function total(list) {
  return list.reduce((s, p) => s + p.price, 0);
}

これは 「.d.ts は書くが .ts には移行しない」 という段階に最適なやり方です。型定義だけ .d.ts に切り出し、JS から @typedef {import(...)} で読み込むことで、巨大リポジトリの全 .js をいきなり .ts に変えなくても、型システムの恩恵を受けられます。

3.5 JSDoc → .d.ts への自動変換

# tsc に JSDoc 付き JS から .d.ts を生成させる
npx tsc --allowJs --declaration --emitDeclarationOnly 
  --outDir types src/**/*.js

JSDoc を貯めておけば、最終的に .d.ts が自動生成され、ライブラリ公開時にもそのまま利用できます。「JSDoc は移行のための一時的なコストではなく、長期的な型資産になる」と捉えるのが正解です。

4. ファイル拡張子の置換: .js → .ts

JSDoc である程度型を入れたら、いよいよ拡張子そのものを .ts に切り替えていきます。重要なのは 「末端モジュールから順番に」 進めることです。コアモジュールから先に変えると、import 元すべての型が崩れてエラーが氾濫します。

4.1 推奨の順序

1. 純粋関数・ユーティリティ (src/utils/*.js)
2. 型のないドメインモデル (src/domain/*.js)
3. 外部 I/O ラッパー (src/infra/*.js)
4. ルーティング / コントローラ (src/routes/*.js)
5. エントリポイント (src/index.js → src/index.ts は最後)

4.2 拡張子変更の実コマンド

# 1 ファイル単位で(履歴を残すために git mv 推奨)
git mv src/utils/calc.js src/utils/calc.ts

# まとめて utils/ 配下を移行
for f in src/utils/*.js; do
  git mv "$f" "${f%.js}.ts"
done

git status

4.3 拡張子変更直後によく出る型エラーと対処

// Before: utils/calc.js
function add(a, b) { return a + b; }
module.exports = { add };

// After: utils/calc.ts (とりあえず any を許容してエラーを止める)
export function add(a: any, b: any): any {
  return a + b;
}

// 次のコミットでまともな型を入れる
export function add(a: number, b: number): number {
  return a + b;
}

移行初期は any を一旦許容してでも、まずビルドを通す」 ことが最優先です。型を正しく入れる作業は別 PR に分割しましょう。

5. CommonJS から ESM へ

TypeScript 移行と同時に モジュール形式の刷新 もセットで検討されることが多いです。Node.js 公式は完全に ESM を主軸にしており、ライブラリ生態系も大半が ESM に移行済みです。

5.1 require / module.exports の置換

// Before: CommonJS
const fs = require("fs");
const { join } = require("path");

function loadJson(file) {
  return JSON.parse(fs.readFileSync(file, "utf-8"));
}

module.exports = { loadJson };
// After: ESM + TypeScript
import { readFileSync } from "node:fs";
import { join } from "node:path";

export function loadJson<T>(file: string): T {
  return JSON.parse(readFileSync(file, "utf-8")) as T;
}

5.2 default export と named export

// Before: CommonJS
module.exports = function logger(level) {
  return (msg) => console.log(`[${level}] ${msg}`);
};

// After: ESM
export default function logger(level: string) {
  return (msg: string): void => console.log(`[${level}] ${msg}`);
}

5.3 __dirname / __filename を ESM で復元

// Before: CommonJS では暗黙に存在
const here = __dirname;

// After: ESM では import.meta.url から組み立てる
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);

5.4 package.json の “type” 切替

{
  "name": "my-app",
  "type": "module",
  "main": "dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

注意: "type": "module" にした瞬間、全ての .js が ESM 扱いになります。.cjs へ拡張子を変えるか、コードを ESM 化するかをファイル単位で判断する必要があります。

6. strict オプションの段階適用

TypeScript の strict は内部的に複数のフラグの集合体です。いきなり全部を有効にすると数百〜数千件のエラーが噴出する ので、必ず段階的に進めます。

6.1 strict の構成要素

{
  "compilerOptions": {
    // これら全てを有効化したのが strict: true と同等
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true
  }
}

6.2 推奨の有効化順序

Step 1: strictNullChecks          (Null/Undefined を撲滅)
Step 2: noImplicitAny             (any を可視化)
Step 3: strictFunctionTypes       (関数の bivariance を制限)
Step 4: noImplicitThis            (this の暗黙any を排除)
Step 5: useUnknownInCatchVariables(catch の e: unknown 化)
Step 6: strictPropertyInitialization
Step 7: strictBindCallApply
Step 8: noUncheckedIndexedAccess  (任意配列アクセスの安全化)
Step 9: exactOptionalPropertyTypes(? と undefined の区別)

6.3 strictNullChecks への対応

// Before: strictNullChecks: false
function getLength(s: string) {
  return s.length;
}
getLength(null); // ← 通ってしまう

// After: strictNullChecks: true
function getLength(s: string | null): number {
  if (s === null) return 0;
  return s.length;
}

// もしくは Optional Chaining + Nullish
function getLength2(s: string | null | undefined): number {
  return s?.length ?? 0;
}

6.4 noImplicitAny への対応

// Before
function format(input) {          // input: any
  return input.toUpperCase();
}

// After
function format(input: string): string {
  return input.toUpperCase();
}

// 一時的な逃げ道: 明示any (将来の検索性のため)
function formatTODO(input: any): any {  // FIXME: type me
  return input.toUpperCase();
}

6.5 noUncheckedIndexedAccess への対応

// Before: 何も付けないと配列アクセスは常に T
const arr: string[] = ["a", "b"];
const x = arr[100];          // x: string (危険)
console.log(x.toUpperCase()); // 実行時に落ちる

// After: noUncheckedIndexedAccess: true
const y = arr[100];          // y: string | undefined
console.log(y?.toUpperCase()); // 安全

6.6 exactOptionalPropertyTypes への対応

// Before: ? と undefined を同一視
interface Config { theme?: string }

const c1: Config = { theme: undefined }; // 通る

// After: exactOptionalPropertyTypes: true
interface Config { theme?: string }

const c2: Config = { theme: undefined };
//                          ^^^^^^^^^ Error: undefined を明示的に入れるな
const c3: Config = {};                   // OK (省略)
const c4: Config = { theme: "dark" };    // OK

6.7 useUnknownInCatchVariables への対応

// Before: catch の e は暗黙 any
try { ... } catch (e) { console.log(e.message); }

// After: e は unknown
try {
  riskyCall();
} catch (e) {
  if (e instanceof Error) {
    console.error(e.message);
  } else {
    console.error("unknown error", e);
  }
}

7. 型定義パッケージ (@types) と外部ライブラリ

移行プロジェクトの大半の「赤線」は、自前コードではなく 外部ライブラリの型不足から発生します。@types/* の扱いを正しく押さえておかないと、ここで数日溶かします。

7.1 @types のインストール

npm i -D @types/node
npm i -D @types/express
npm i -D @types/lodash
npm i -D @types/jest

7.2 「型が無い」ライブラリを救う 3 つの手段

// 手段 1: declare module で最低限のシグネチャを入れる
// src/types/legacy-lib.d.ts
declare module "legacy-lib" {
  export function doSomething(x: number): string;
}

// 利用側
import { doSomething } from "legacy-lib";
const r: string = doSomething(42);
// 手段 2: 全部 any にして黙らせる (最終手段)
declare module "very-legacy-lib";

// 手段 3: 環境変数や window 拡張
// src/types/global.d.ts
declare global {
  interface Window {
    __APP_CONFIG__: {
      apiBase: string;
      version: string;
    };
  }
}
export {};

7.3 アンビエント宣言で「動的にロードされる定数」を型化

// Webpack DefinePlugin / Vite define で注入されている定数
declare const __BUILD_ID__: string;
declare const __DEV__: boolean;

if (__DEV__) {
  console.log("build:", __BUILD_ID__);
}

8. 自動移行ツール ts-migrate と jscodeshift

「数百ファイルを 1 つずつ手で変えるのは現実的でない」── そのためのツールが存在します。完璧ではありませんが、80 点まで一気に進める用途に有効です。

8.1 ts-migrate の導入

npm i -D ts-migrate

# 初期化
npx ts-migrate-full ./src
# 内部で「rename」「init」「reignore」が連続実行される

8.2 ts-migrate が自動でやってくれること

- .js → .ts へのファイルリネーム
- 引数・戻り値に明示的な any を一括付与
- 型エラー行に // @ts-expect-error を自動挿入
- 既存 JSDoc を可能な限り型注釈へ変換

8.3 jscodeshift を使った require → import 変換

npx jscodeshift -t 
  https://github.com/cpojer/js-codemod/blob/master/transforms/cjs-to-es6-module.js 
  src
// Before
const fs = require("fs");
const { resolve } = require("path");
module.exports = { foo: 1 };

// After (jscodeshift で自動変換)
import fs from "fs";
import { resolve } from "path";
export const foo = 1;

9. lint とフォーマッタの段階移行

ESLint も「JavaScript の眼」と「TypeScript の眼」を同居させる必要があります。

9.1 typescript-eslint 導入

npm i -D 
  @typescript-eslint/parser 
  @typescript-eslint/eslint-plugin 
  eslint

9.2 段階適用の .eslintrc

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "overrides": [
    {
      "files": ["**/*.js"],
      "rules": {
        "@typescript-eslint/no-var-requires": "off",
        "@typescript-eslint/explicit-module-boundary-types": "off"
      }
    },
    {
      "files": ["**/*.ts"],
      "rules": {
        "@typescript-eslint/no-explicit-any": "warn"
      }
    }
  ]
}

9.3 Prettier との衝突を避ける

npm i -D prettier eslint-config-prettier
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ]
}

10. テスト基盤の移行: Jest → Vitest

テストが動かない状態で型移行を進めるのは自殺行為です。移行と並行してテスト基盤も TypeScript フレンドリーにします。Jest は ts-jest 構成で TS 対応可能ですが、Vite を選んでいるなら Vitest が圧倒的に楽です。

10.1 ts-jest による Jest の TS 対応

npm i -D ts-jest @types/jest typescript
npx ts-jest config:init
// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  testMatch: ["**/*.test.ts"],
  moduleFileExtensions: ["ts", "js", "json"],
};

10.2 Vitest への置き換え

npm uninstall jest ts-jest @types/jest
npm i -D vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["src/**/*.test.ts"],
    coverage: { reporter: ["text", "html"] },
  },
});
// 既存 Jest テストはほぼそのまま動く
import { describe, it, expect } from "vitest";
import { add } from "./calc";

describe("add", () => {
  it("returns sum", () => {
    expect(add(1, 2)).toBe(3);
  });
});

11. ビルドツールの移行: Webpack → Vite / tsc

ビルドツールは「Webpack + Babel + ts-loader」「Webpack + swc-loader」「Vite」「tsc 単体」「esbuild / tsup」など選択肢が多く、「どこを TypeScript の責務にするか」を最初に決める必要があります。

11.1 tsc 単体での最小ビルド

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["**/*.test.ts", "node_modules"]
}
{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "typecheck": "tsc --noEmit",
    "dev": "tsc --watch"
  }
}

11.2 Babel + TypeScript

{
  "presets": [
    ["@babel/preset-env", { "targets": { "node": "current" } }],
    "@babel/preset-typescript"
  ]
}

Babel は 型チェックをしない ことに注意してください。Babel は構文を落とすだけなので、型エラー検知は別途 tsc --noEmit を CI に組み込みます。

11.3 Vite による開発サーバ + ビルド

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    target: "es2022",
    lib: {
      entry: "src/index.ts",
      formats: ["es", "cjs"],
      fileName: (format) => `index.${format}.js`,
    },
  },
});

12. フレームワーク別の移行: React / Express

12.1 React プロジェクトの段階移行

# 既存 CRA / Vite プロジェクトに TS を追加
npm i -D typescript @types/react @types/react-dom

# tsconfig だけ用意し、まずは .jsx のままにする
# その後 components/ 配下から .tsx 化
// Before: src/components/Button.jsx
export function Button({ label, onClick }) {
  return <button onClick={onClick}>{label}</button>;
}

// After: src/components/Button.tsx
type ButtonProps = {
  label: string;
  onClick?: () => void;
};

export function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

React の型詳細は React Props 型定義完全ガイドTypeScript×カスタムフック型定義完全ガイド を参照してください。

12.2 Express を TypeScript で書き直す

npm i express
npm i -D @types/express @types/node
// Before: src/server.js (CJS)
const express = require("express");
const app = express();
app.get("/users/:id", (req, res) => {
  res.json({ id: req.params.id });
});
app.listen(3000);
// After: src/server.ts (ESM + 型)
import express, { Request, Response } from "express";

type UserParams = { id: string };
type UserResponse = { id: string; name: string };

const app = express();

app.get(
  "/users/:id",
  (req: Request<UserParams>, res: Response<UserResponse>) => {
    res.json({ id: req.params.id, name: "Alice" });
  }
);

app.listen(3000, () => console.log("listening 3000"));

12.3 Node.js プロジェクトのモノレポ移行

// 親 tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "incremental": true,
    "outDir": "dist"
  },
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/web" },
    { "path": "./packages/cli" }
  ]
}
# すべてのプロジェクトを参照ビルド
npx tsc -b

# 監視モード
npx tsc -b --watch

13. CI/CD とレビュー戦略

13.1 GitHub Actions での型チェック

name: ci

on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run typecheck
      - run: npm test

13.2 「型エラー数」を回帰指標にする

# tsc が出すエラー数を集計してアーティファクトに保存
npx tsc --noEmit 2> ts-errors.log || true
ERRORS=$(grep -c "error TS" ts-errors.log || echo 0)
echo "TS errors: $ERRORS"
# 前回より増えていたら CI を fail させる

13.3 PR 単位のレビュー戦略

- PR 1 本あたり「.js → .ts」 は最大 5 ファイル
- 「拡張子変更だけの PR」と「型を入れる PR」を分ける
- 型を変える PR には「影響範囲: 」を必須ラベル化
- レビュアーは差分のうち // @ts-ignore の追加を必ず指摘
- 大規模リネームPRは codeowners を一時的に外しブロックを避ける

13.4 チーム合意のためのチェックリスト

[ ] 移行完了の定義 (Definition of Done) を決めた?
    例: strict: true / @ts-ignore 0件 / coverage 80%
[ ] 期限 (3ヶ月後 / 6ヶ月後)を区切った?
[ ] 「リライト禁止リスト」を作った? (動いてる重要モジュール)
[ ] 緊急修正時の例外フロー (any 一時導入 OK)は明文化済み?
[ ] 学習コスト (社内勉強会・基準コード)を担保した?

14. よくある罠と回避策

14.1 「@ts-ignore の永久増殖」を防ぐ

// 悪い例: 理由のない ignore
// @ts-ignore
const x: number = "string";

// 良い例: チケット番号 + 期限を必ず付ける
// @ts-expect-error TODO(#1234): 2026-09 までに正しい型に修正
const x: number = "string" as unknown as number;

14.2 「全部 any」病からの脱出

// 段階1: any のままでも、関数境界には型を付ける
function process(input: unknown): string {
  // ↑ unknown を入口に置くだけで利用側が型ガードを強制される
  if (typeof input !== "string") return "";
  return input.toUpperCase();
}

関数の 「外周」 から型を固めると、内側がまだ any でも安全性が一気に上がります。詳しくは TypeScript 型ガード完全ガイド を参照してください。

14.3 「any と unknown の混同」

function a(x: any) { x.foo.bar.baz; }       // 何も検出されない
function b(x: unknown) { x.foo.bar.baz; }    // ← エラー (安全)

// unknown は「使う前に絞れ」と強制してくれる
function c(x: unknown): string {
  if (typeof x === "string") return x;
  return JSON.stringify(x);
}

14.4 「インデックスシグネチャでの穴」

// noUncheckedIndexedAccess を有効にしていないと…
interface Dict { [key: string]: string }
const d: Dict = {};
const v = d["nope"];   // v: string (undefined にならない !)
v.toUpperCase();        // 実行時に落ちる

// 有効化後
const w = d["nope"];   // w: string | undefined (型レベルで救える)

14.5 「default export 地獄」

// Before
export default function () { ... }    // 名前が呼び出し側依存
import myFunc from "./x";              // 何でも名前を付けられる

// After (Named export 推奨)
export function doSomething() { ... }
import { doSomething } from "./x";     // grep でも追える

15. 移行完了の判定とこれから

「TypeScript 化完了」を定量的に判定できないと、いつまで経っても移行プロジェクトは終わりません。次のような指標を 1 つの大きなダッシュボードに集約しましょう。

15.1 移行 KPI の例

- .ts / (.ts + .js) のファイル比率
- // @ts-ignore / @ts-expect-error の総数 (減少傾向か)
- noImplicitAny / strictNullChecks のオン/オフ
- tsc --noEmit のエラー件数
- 自動生成 .d.ts でカバーされているライブラリ比率
- type "module" 化が済んだ package 数

15.2 KPI 計測スクリプト

#!/usr/bin/env bash
# scripts/ts-migration-status.sh
set -e
TS=$(find src -name "*.ts" | wc -l)
JS=$(find src -name "*.js" | wc -l)
IGN=$(grep -rE "@ts-(ignore|expect-error)" src | wc -l)
TOTAL=$((TS + JS))
RATIO=$(awk "BEGIN { printf "%.1f", $TS * 100 / $TOTAL }")

echo "TS files : $TS"
echo "JS files : $JS"
echo "Migrated : ${RATIO}%"
echo "Ignored  : $IGN lines"

15.3 「終わった」あとに学習を継続する

移行が完了すれば終わりではなく、ここから 「TypeScript を活かしきる」 フェーズが始まります。マップ型・条件型・テンプレートリテラル型などを駆使したライブラリ設計、Zod など実行時バリデータとの組み合わせ、Server Actions やトランスポート層の型共有など、次のステップは無数にあります。

16. まとめ: 段階移行 9 か条

1. いきなり .ts 化するな。まず allowJs + // @ts-check で観測。
2. 型は JSDoc から始めても良い。資産は .d.ts に蓄積される。
3. 拡張子の置換は末端モジュールから。エントリは最後。
4. strict は 1 オプションずつ。Null → Any → IndexAccess の順。
5. CommonJS / ESM の混在は package.json の "type" で必ず決着。
6. @types が無いライブラリは declare module で最低限のシグネチャを。
7. lint / formatter / test も同じタイミングで TS 対応する。
8. CI に「型エラー数の回帰検知」を入れて後退を防ぐ。
9. ゴール (DoD) と KPI を決めて、移行プロジェクトを終わらせる。

「動いている JavaScript を壊さずに TypeScript へ寄せていく」── このプロセスは、技術的にも組織的にも長丁場になります。本記事の手順を 「一度に全部やる」のではなく「現状のリポジトリから 1 つだけ選んで今週やる」 ことから始めてみてください。1 ファイル // @ts-check を追加するだけでも、明日からプロジェクトの型安全性は確実に一段上がります。

コメント

タイトルとURLをコピーしました