「既存の JavaScript プロジェクトに、ある日突然 TypeScript を全面導入する」── 多くの現場でこの選択肢は 非現実的 です。何千行ものコードを一晩で書き換えることはできず、ビルドが止まれば事業が止まります。本記事では、TypeScript 5.x を前提に 「動いている JavaScript を壊さずに、段階的に TypeScript へ移行する」 ための完全なロードマップを、40 個以上のコピペで動くコード断片とともに整理します。
allowJs による JS と TS の共存、// @ts-check と JSDoc を使った「ファイル拡張子を変えずに型を入れる」テクニック、strict を 1 オプションずつ有効化していく安全な順序、require → import や CommonJS → ESM の置換、Jest → Vitest や Webpack → Vite といったツール側の移行、CI/CD と PR レビュー戦略まで、「明日から既存リポジトリで使える具体的な手順」として書き下ろしています。型システムそのものの解説は TypeScript 型基礎、tsconfig.json の細部は tsconfig.json 完全ガイド を併読してください。
- 1. 移行前の現状把握とリスク評価
- 2. 共存フェーズ: allowJs と checkJs
- 3. JSDoc による型注釈という選択肢
- 3.5 JSDoc → .d.ts への自動変換
- 4. ファイル拡張子の置換: .js → .ts
- 5. CommonJS から ESM へ
- 6. strict オプションの段階適用
- 7. 型定義パッケージ (@types) と外部ライブラリ
- 8. 自動移行ツール ts-migrate と jscodeshift
- 9. lint とフォーマッタの段階移行
- 10. テスト基盤の移行: Jest → Vitest
- 11. ビルドツールの移行: Webpack → Vite / tsc
- 12. フレームワーク別の移行: React / Express
- 13. CI/CD とレビュー戦略
- 14. よくある罠と回避策
- 15. 移行完了の判定とこれから
- 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 やトランスポート層の型共有など、次のステップは無数にあります。
- TypeScript ジェネリクス完全ガイド
- Mapped Types 完全ガイド
- 条件型 (Conditional Types) 完全ガイド
- Template Literal Types 完全ガイド
- tsconfig.json 完全ガイド
- Zod 完全実践ガイド
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 を追加するだけでも、明日からプロジェクトの型安全性は確実に一段上がります。

コメント